diff options
59 files changed, 4048 insertions, 3222 deletions
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..0644048 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +5cf86bb7849f2b78711a5576bba15299613fe148 # cargo fmt +1e815637e3e15c7eb81b45b51b40253f3ec57ebb # kittybox-html: cargo fmt \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6df432e..b660440 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -/kittybox-rs/target -.direnv +/target +/.direnv result-* result dump.rdb @@ -10,9 +10,8 @@ dump.rdb *~ *.log *.log.json -/kittybox-rs/test-dir -/kittybox-rs/media-store -/kittybox-rs/auth-store -/kittybox-rs/fonts/* -/kittybox-rs/companion-lite/dist + +/media-store +/auth-store +/companion-lite/dist /token.txt diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..09cfd7b --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,8 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "format_on_save": "on", + "languages": { "Rust": { "format_on_save": "language_server" } } +} diff --git a/Cargo.lock b/Cargo.lock index bc54f91..a3b5d7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,7 +42,7 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -131,19 +131,20 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", + "once_cell", "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arc-swap" @@ -165,9 +166,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.3.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ff05a702273012438132f449575dbc804e27b2f3cbe3069aa237d26c98fa33" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -181,25 +182,25 @@ dependencies = [ [[package]] name = "asn1-rs-derive" -version = "0.1.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b7511298d5b7784b40b092d9e9dcd3a627a5707e4b5e507931ab0d44eeebf" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", - "synstructure 0.12.6", + "syn", + "synstructure", ] [[package]] name = "asn1-rs-impl" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] @@ -221,14 +222,14 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.93", + "syn", ] [[package]] name = "async-compression" -version = "0.4.18" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" +checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64" dependencies = [ "brotli", "flate2", @@ -268,18 +269,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "async-trait" -version = "0.1.83" +version = "0.1.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -293,9 +294,9 @@ dependencies = [ [[package]] name = "atom_syndication" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec03a6e158ee0f38bfba811976ae909bc2505a4a2f4049c7e8df47df3497b119" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" dependencies = [ "chrono", "derive_builder", @@ -312,13 +313,13 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_impl" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -329,9 +330,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" dependencies = [ "axum-core", "axum-macros", @@ -365,12 +366,12 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", @@ -385,9 +386,9 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" dependencies = [ "axum", "axum-core", @@ -400,6 +401,7 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", + "rustversion", "serde", "tower", "tower-layer", @@ -414,7 +416,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -434,12 +436,6 @@ dependencies = [ [[package]] name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" @@ -452,26 +448,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - -[[package]] -name = "base64urlsafedata" -version = "0.1.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18b3d30abb74120a9d5267463b9e0045fdccc4dd152e7249d966612dc1721384" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" [[package]] name = "base64urlsafedata" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56894edf5cd1efa7068d7454adeb7ce0b3da4ffa5ab08cfc06165bbc62f0c7" +checksum = "72f0ad38ce7fbed55985ad5b2197f05cff8324ee6eb6638304e78f0108fae56c" dependencies = [ "base64 0.21.7", "paste", @@ -480,9 +465,9 @@ dependencies = [ [[package]] name = "better_scoped_tls" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794edcc9b3fb07bb4aecaa11f093fd45663b4feadb782d68303a2268bc2701de" +checksum = "297b153aa5e573b5863108a6ddc9d5c968bd0b20e75cc614ee9821d2f45679c7" dependencies = [ "scoped-tls", ] @@ -498,15 +483,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.6.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" dependencies = [ "serde", ] @@ -542,9 +521,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "4.0.1" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -552,9 +531,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "byteorder" @@ -564,15 +543,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.6" +version = "1.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" dependencies = [ "shlex", ] @@ -591,9 +570,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", @@ -601,14 +580,14 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "clap" -version = "4.5.23" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", "clap_derive", @@ -616,9 +595,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.23" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", @@ -628,14 +607,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.18" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -666,12 +645,12 @@ dependencies = [ [[package]] name = "compact_jwt" -version = "0.2.10" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa76ef19968577838a34d02848136bb9b6bdbfd7675fb968fe9c931bc434b33" +checksum = "12bbab6445446e8d0b07468a01d0bfdae15879de5c440c5e47ae4ae0e18a1fba" dependencies = [ - "base64 0.13.1", - "base64urlsafedata 0.1.3", + "base64 0.21.7", + "base64urlsafedata", "hex", "openssl", "serde", @@ -705,7 +684,7 @@ dependencies = [ "base64 0.22.1", "hmac", "percent-encoding", - "rand", + "rand 0.8.5", "sha2", "subtle", "time", @@ -739,9 +718,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -772,9 +751,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -790,9 +769,9 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] @@ -815,9 +794,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -825,27 +804,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.93", + "syn", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -856,9 +835,9 @@ checksum = "04d2cd9c18b9f454ed67da600630b021a8a80bf33f8c95896ab33aaf1c26b728" [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "deadpool" @@ -880,9 +859,9 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -891,9 +870,9 @@ dependencies = [ [[package]] name = "der-parser" -version = "7.0.0" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", @@ -905,9 +884,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", "serde", @@ -931,7 +910,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -941,14 +920,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.93", + "syn", ] [[package]] name = "deunicode" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" +checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" [[package]] name = "digest" @@ -979,7 +958,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -990,9 +969,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -1017,15 +996,15 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", "windows-sys 0.59.0", @@ -1044,9 +1023,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" dependencies = [ "concurrent-queue", "parking", @@ -1055,9 +1034,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ "event-listener", "pin-project-lite", @@ -1071,7 +1050,7 @@ checksum = "300d2ddbf2245b5b5e723995e0961033121b4fc2be9045fb661af82bd739ffb6" dependencies = [ "deunicode", "lazy_static", - "rand", + "rand 0.8.5", ] [[package]] @@ -1082,9 +1061,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", "miniz_oxide", @@ -1092,9 +1071,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", @@ -1108,6 +1087,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1139,7 +1124,7 @@ checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.93", + "syn", ] [[package]] @@ -1219,7 +1204,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -1253,6 +1238,19 @@ dependencies = [ ] [[package]] +name = "generator" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bd114ceda131d3b1d665eba35788690ad37f5916457286b32ab6fd3c438dd" +dependencies = [ + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1271,7 +1269,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] @@ -1283,9 +1295,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633" dependencies = [ "atomic-waker", "bytes", @@ -1321,14 +1333,19 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.2", ] [[package]] @@ -1393,18 +1410,18 @@ dependencies = [ [[package]] name = "home" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "hstr" -version = "0.2.10" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96274be293b8877e61974a607105d09c84caebe9620b47774aa8a6b942042dd4" +checksum = "a1a26def229ea95a8709dad32868d975d0dd40235bd2ce82920e4a8fe692b5e0" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", @@ -1415,21 +1432,6 @@ dependencies = [ ] [[package]] -name = "html" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "944d7db81871c611549302f3014418fedbcfbc46902f97e6a1c4f53e785903d2" -dependencies = [ - "html-sys", -] - -[[package]] -name = "html-sys" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13eca55667a5657dd1b86db77c5fe2d1810e3f9413e9555a2c4c461733dd2573" - -[[package]] name = "html5ever" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1440,14 +1442,14 @@ dependencies = [ "markup5ever", "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "http" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -1466,12 +1468,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -1479,9 +1481,9 @@ dependencies = [ [[package]] name = "http-cache" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b65cd1687caf2c7fff496741a2f264c26f54e6d6cec03dac8f276fa4e5430e" +checksum = "7e883defacf53960c7717d9e928dc8667be9501d9f54e6a8b7703d7a30320e9c" dependencies = [ "async-trait", "bincode", @@ -1495,9 +1497,9 @@ dependencies = [ [[package]] name = "http-cache-reqwest" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735586904a5ce0c13877c57cb4eb8195eb7c11ec1ffd64d4db053fb8559ca62e" +checksum = "e076afd9d376f09073b515ce95071b29393687d98ed521948edb899195595ddf" dependencies = [ "anyhow", "async-trait", @@ -1534,9 +1536,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.5" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1546,9 +1548,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -1601,9 +1603,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" dependencies = [ "bytes", "futures-channel", @@ -1611,6 +1613,7 @@ dependencies = [ "http", "http-body", "hyper", + "libc", "pin-project-lite", "socket2", "tokio", @@ -1620,16 +1623,17 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.0", ] [[package]] @@ -1682,9 +1686,9 @@ dependencies = [ [[package]] name = "icu_locid_transform_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" [[package]] name = "icu_normalizer" @@ -1706,9 +1710,9 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" @@ -1727,9 +1731,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" @@ -1756,7 +1760,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -1807,9 +1811,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -1817,20 +1821,20 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "is-macro" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a85abdc13717906baccb5a1e435556ce0df215f242892f721dff62bf25288f" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "Inflector", + "heck", "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -1850,15 +1854,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -1892,8 +1896,7 @@ dependencies = [ "microformats", "mime", "newbase60", - "prometheus", - "rand", + "rand 0.8.5", "redis", "relative-path", "reqwest", @@ -1906,7 +1909,7 @@ dependencies = [ "sqlparser", "sqlx", "tempfile", - "thiserror 2.0.9", + "thiserror 2.0.12", "tokio", "tokio-stream", "tokio-util", @@ -1938,43 +1941,20 @@ dependencies = [ "libflate", "markup", "microformats", - "rand", - "serde_json", - "time", - "walkdir", -] - -[[package]] -name = "kittybox-html" -version = "0.2.0" -dependencies = [ - "axum", - "chrono", - "ellipse", - "faker_rand", - "html", - "http", - "include_dir", - "kittybox-indieauth", - "kittybox-util", - "libflate", - "microformats", - "rand", + "rand 0.8.5", "serde_json", - "thiserror 2.0.9", "time", - "url", "walkdir", ] [[package]] name = "kittybox-indieauth" -version = "0.2.0" +version = "0.3.3" dependencies = [ "axum-core", "data-encoding", "http", - "rand", + "rand 0.8.5", "serde", "serde_json", "serde_urlencoded", @@ -1989,7 +1969,7 @@ dependencies = [ "axum-core", "futures-util", "http", - "rand", + "rand 0.8.5", "serde", "serde_json", "sqlx", @@ -2009,9 +1989,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libflate" @@ -2049,22 +2029,21 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "listenfd" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0500463acd96259d219abb05dc57e5a076ef04b2db9a2112846929b5f174c96" +checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba" dependencies = [ "libc", "uuid", @@ -2073,9 +2052,9 @@ dependencies = [ [[package]] name = "litemap" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "lock_api" @@ -2089,9 +2068,22 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] [[package]] name = "mac" @@ -2101,9 +2093,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "markdown" -version = "1.0.0-alpha.21" +version = "1.0.0-alpha.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6491e6c702bf7e3b24e769d800746d5f2c06a6c6a2db7992612e0f429029e81" +checksum = "9047e0a37a596d4e15411a1ffbdabe71c328908cb90a721cb9bf8dcf3434e6d2" dependencies = [ "unicode-id", ] @@ -2125,7 +2117,7 @@ checksum = "9ab6ee21fd1855134cacf2f41afdf45f1bc456c7d7f6165d763b4647062dd2be" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -2200,9 +2192,9 @@ dependencies = [ [[package]] name = "microformats-types" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa919e7c1dc484fb93bfb099e1329693616102aa6685c9277f9621501440cf16" +checksum = "42af6210be88131a5a359729034b12d0d5bc5502f2444d0bb4414346aec2fa46" dependencies = [ "lazy_static", "regex", @@ -2227,9 +2219,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" dependencies = [ "adler2", ] @@ -2241,31 +2233,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] [[package]] name = "moka" -version = "0.12.8" +version = "0.12.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" dependencies = [ "async-lock", - "async-trait", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "event-listener", "futures-util", - "once_cell", + "loom", "parking_lot", - "quanta", + "portable-atomic", "rustc_version", "smallvec", "tagptr", "thiserror 1.0.69", - "triomphe", "uuid", ] @@ -2288,9 +2278,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", @@ -2372,7 +2362,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -2434,26 +2424,26 @@ dependencies = [ [[package]] name = "oid-registry" -version = "0.4.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e20717fa0541f39bd146692035c37bedfa532b3e5071b35761082407546b2a" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" dependencies = [ "asn1-rs", ] [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2470,20 +2460,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.104" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", @@ -2521,7 +2511,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.8", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -2533,7 +2523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2560,86 +2550,67 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "phf" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_shared 0.11.2", + "phf_shared", ] [[package]] name = "phf_codegen" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", + "phf_generator", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand", -] - -[[package]] -name = "phf_generator" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" -dependencies = [ - "phf_shared 0.11.2", - "rand", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "siphasher", + "phf_shared", + "rand 0.8.5", ] [[package]] name = "phf_shared" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.1", ] [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -2670,9 +2641,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "powerfmt" @@ -2682,11 +2659,11 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.24", ] [[package]] @@ -2697,79 +2674,18 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] -name = "procfs" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" -dependencies = [ - "bitflags 2.6.0", - "hex", - "lazy_static", - "procfs-core", - "rustix", -] - -[[package]] -name = "procfs-core" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" -dependencies = [ - "bitflags 2.6.0", - "hex", -] - -[[package]] -name = "prometheus" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "libc", - "memchr", - "parking_lot", - "procfs", - "protobuf", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf" -version = "2.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" - -[[package]] -name = "quanta" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773ce68d0bb9bc7ef20be3536ffe94e223e1f365bd374108b2659fac0c65cfe6" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - -[[package]] name = "quick-xml" -version = "0.37.2" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" dependencies = [ "encoding_rs", "memchr", @@ -2777,37 +2693,39 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" dependencies = [ "bytes", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.0", + "rustc-hash 2.1.1", "rustls", "socket2", - "thiserror 2.0.9", + "thiserror 2.0.12", "tokio", "tracing", + "web-time", ] [[package]] name = "quinn-proto" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" dependencies = [ "bytes", - "getrandom", - "rand", + "getrandom 0.3.2", + "rand 0.9.1", "ring", - "rustc-hash 2.1.0", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.9", + "thiserror 2.0.12", "tinyvec", "tracing", "web-time", @@ -2815,9 +2733,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" dependencies = [ "cfg_aliases", "libc", @@ -2829,22 +2747,38 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2854,7 +2788,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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]] @@ -2863,16 +2807,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] -name = "raw-cpuid" -version = "11.2.0" +name = "rand_core" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "bitflags 2.6.0", + "getrandom 0.3.2", ] [[package]] @@ -2901,20 +2845,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" -dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -2969,9 +2904,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "async-compression", "base64 0.22.1", @@ -3018,9 +2953,9 @@ dependencies = [ [[package]] name = "reqwest-middleware" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ccd3b55e711f91a9885a2fa6fbbb2e39db1776420b062efc058c6410f7e5e3" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" dependencies = [ "anyhow", "async-trait", @@ -3033,15 +2968,14 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -3054,9 +2988,9 @@ checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ "const-oid", "digest", @@ -3065,7 +2999,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -3086,9 +3020,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -3110,11 +3044,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.42" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -3123,9 +3057,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.20" +version = "0.23.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" +checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" dependencies = [ "once_cell", "ring", @@ -3146,18 +3080,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" dependencies = [ "web-time", ] [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" dependencies = [ "ring", "rustls-pki-types", @@ -3166,15 +3100,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -3212,7 +3146,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -3221,9 +3155,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1863fd3768cd83c56a7f60faa4dc0d403f1b6df0a38c3c25f44b7894e45370d5" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -3231,15 +3165,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] @@ -3256,20 +3190,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "serde_json" -version = "1.0.134" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "indexmap", "itoa", @@ -3280,9 +3214,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", @@ -3354,9 +3288,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "a1ee1aca2bc74ef9589efa7ccaa0f3752751399940356209b3fd80c078149b5e" dependencies = [ "libc", ] @@ -3368,7 +3302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3378,6 +3312,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3388,18 +3328,18 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" dependencies = [ "libc", "windows-sys 0.52.0", @@ -3425,16 +3365,6 @@ dependencies = [ ] [[package]] -name = "sqlformat" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f" -dependencies = [ - "nom", - "unicode_categories", -] - -[[package]] name = "sqlparser" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3447,9 +3377,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" +checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3460,41 +3390,35 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" +checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" dependencies = [ - "atoi", - "byteorder", + "base64 0.22.1", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.14.5", + "hashbrown 0.15.2", "hashlink", - "hex", "indexmap", "log", "memchr", "native-tls", "once_cell", - "paste", "percent-encoding", "rustls", - "rustls-pemfile", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-stream", "tracing", @@ -3505,22 +3429,22 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" +checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.93", + "syn", ] [[package]] name = "sqlx-macros-core" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" +checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" dependencies = [ "dotenvy", "either", @@ -3536,7 +3460,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.93", + "syn", "tempfile", "tokio", "url", @@ -3544,13 +3468,13 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" +checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3572,7 +3496,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -3580,7 +3504,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror 2.0.12", "tracing", "uuid", "whoami", @@ -3588,13 +3512,13 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" +checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags", "byteorder", "chrono", "crc", @@ -3602,7 +3526,6 @@ dependencies = [ "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -3613,14 +3536,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror 2.0.12", "tracing", "uuid", "whoami", @@ -3628,9 +3551,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" +checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" dependencies = [ "atoi", "chrono", @@ -3646,6 +3569,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror 2.0.12", "tracing", "url", "uuid", @@ -3659,26 +3583,25 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "string_cache" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", - "once_cell", "parking_lot", - "phf_shared 0.10.0", + "phf_shared", "precomputed-hash", "serde", ] [[package]] name = "string_cache_codegen" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -3692,7 +3615,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.93", + "syn", ] [[package]] @@ -3746,7 +3669,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "serde", - "siphasher", + "siphasher 0.3.11", "swc_atoms", "swc_eq_ignore_macros", "swc_visit", @@ -3763,7 +3686,7 @@ checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -3786,7 +3709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4c28899c6d01596124686dae5d139412488f4066d013a6c5691e497f5e9a98f" dependencies = [ "auto_impl", - "bitflags 2.6.0", + "bitflags", "rustc-hash 1.1.0", "swc_atoms", "swc_common", @@ -3804,7 +3727,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.93", + "syn", ] [[package]] @@ -3834,13 +3757,13 @@ dependencies = [ [[package]] name = "swc_macros_common" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f486687bfb7b5c560868f69ed2d458b880cebc9babebcb67e49f31b55c5bf847" +checksum = "27e18fbfe83811ffae2bb23727e45829a0d19c6870bced7c0f545cc99ad248dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -3863,25 +3786,14 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.93", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "syn", ] [[package]] name = "syn" -version = "2.0.93" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -3899,25 +3811,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] - -[[package]] -name = "synstructure" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -3928,12 +3828,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.14.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.3.2", "once_cell", "rustix", "windows-sys 0.59.0", @@ -3961,11 +3861,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.9" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.9", + "thiserror-impl 2.0.12", ] [[package]] @@ -3976,18 +3876,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.9" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -4002,9 +3902,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.37" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", @@ -4017,15 +3917,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -4043,9 +3943,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] @@ -4058,9 +3958,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.42.0" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", @@ -4077,13 +3977,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -4098,9 +3998,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ "rustls", "tokio", @@ -4132,9 +4032,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" dependencies = [ "bytes", "futures-core", @@ -4165,7 +4065,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ - "bitflags 2.6.0", + "bitflags", "bytes", "futures-util", "http", @@ -4234,7 +4134,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -4319,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.93", + "syn", ] [[package]] @@ -4345,9 +4245,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" dependencies = [ "serde", "stable_deref_trait", @@ -4361,15 +4261,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-id" @@ -4379,48 +4279,36 @@ checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" - -[[package]] -name = "unicode-xid" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" - -[[package]] -name = "unicode_categories" -version = "0.1.1" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "untrusted" @@ -4466,19 +4354,19 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.11.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom", + "getrandom 0.3.2", "serde", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -4518,6 +4406,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4525,34 +4422,35 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.93", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.49" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", @@ -4563,9 +4461,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4573,22 +4471,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-streams" @@ -4605,9 +4506,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -4625,11 +4526,11 @@ dependencies = [ [[package]] name = "webauthn-attestation-ca" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b0f2ebaf5650ca15b515a761f31ed6477fa2312491cf632a71102ac22b82784" +checksum = "29e77e8859ecb93b00e4a8e56ae45f8a8dd69b1539e3d32cf4cce1db9a3a0b99" dependencies = [ - "base64urlsafedata 0.5.0", + "base64urlsafedata", "openssl", "serde", "tracing", @@ -4638,11 +4539,11 @@ dependencies = [ [[package]] name = "webauthn-rs" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb9d7cdc9ec26e3e06f7e8ee1433e6fa3627c6c075ab3effbc3a2280c2f526c0" +checksum = "8b44347ee0d66f222043663a6aaf5ec78022b9b11c3a9ed488c21f2bd5680856" dependencies = [ - "base64urlsafedata 0.5.0", + "base64urlsafedata", "serde", "tracing", "url", @@ -4652,19 +4553,19 @@ dependencies = [ [[package]] name = "webauthn-rs-core" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1ee1dc7f4138b8fd05a74a6eae93ddaf504c5a60861f1eb95d9de3172900b3" +checksum = "2ef48f07ed8f3dfe304d6c48e85317feba0439675f31a13063b2936c9b4eaf0d" dependencies = [ "base64 0.21.7", - "base64urlsafedata 0.5.0", + "base64urlsafedata", "compact_jwt", "der-parser", "hex", "nom", "openssl", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "serde", "serde_cbor_2", "serde_json", @@ -4679,12 +4580,12 @@ dependencies = [ [[package]] name = "webauthn-rs-proto" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f1c6dc254607f48eec3bdb35b86b377202436859ca1e4c9290afafd7349dcc3" +checksum = "14e1367f70e7dc7b83afc971ce8a54d578f4fdf488ea093021180e073744a69f" dependencies = [ "base64 0.21.7", - "base64urlsafedata 0.5.0", + "base64urlsafedata", "serde", "serde_json", "url", @@ -4692,20 +4593,20 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.7" +version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" dependencies = [ - "redox_syscall 0.4.1", + "redox_syscall", "wasite", ] @@ -4741,23 +4642,100 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] name = "windows-core" -version = "0.52.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] [[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link", + "windows-result 0.3.2", + "windows-strings 0.4.0", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] name = "windows-registry" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result", - "windows-strings", - "windows-targets 0.52.6", + "windows-result 0.3.2", + "windows-strings 0.3.1", + "windows-targets 0.53.0", ] [[package]] @@ -4770,16 +4748,43 @@ dependencies = [ ] [[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] [[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4830,7 +4835,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "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", @@ -4838,6 +4843,22 @@ dependencies = [ ] [[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4850,6 +4871,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4862,6 +4889,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4874,12 +4907,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4892,6 +4937,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4904,6 +4955,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4916,6 +4973,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4928,10 +4991,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] name = "wiremock" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fff469918e7ca034884c7fd8f93fe27bacb7fcb599fd879df6c7b429a29b646" +checksum = "101681b74cd87b5899e87bcf5a64e83334dd313fcd3053ea72e6dba18928e301" dependencies = [ "assert-json-diff", "async-trait", @@ -4952,6 +5021,15 @@ dependencies = [ ] [[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] name = "write16" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4965,12 +5043,11 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "x509-parser" -version = "0.13.2" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9bace5b5589ffead1afb76e43e34cff39cd0f3ce7e170ae0c29e53b88eb1c" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" dependencies = [ "asn1-rs", - "base64 0.13.1", "data-encoding", "der-parser", "lazy_static", @@ -5001,8 +5078,8 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", - "synstructure 0.13.1", + "syn", + "synstructure", ] [[package]] @@ -5011,8 +5088,16 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ - "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", ] [[package]] @@ -5023,28 +5108,39 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", - "synstructure 0.13.1", + "syn", + "synstructure", ] [[package]] @@ -5072,5 +5168,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.93", + "syn", ] diff --git a/Cargo.toml b/Cargo.toml index bf14ded..ab00df7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tokio_unstable)'] } [features] default = ["rustls", "postgres"] webauthn = ["openssl", "dep:webauthn"] -openssl = ["reqwest/native-tls", "reqwest/native-tls-alpn", "sqlx/tls-native-tls"] +openssl = [ + "reqwest/native-tls", + "reqwest/native-tls-alpn", + "sqlx/tls-native-tls", +] rustls = ["reqwest/rustls-tls-webpki-roots", "sqlx/tls-rustls"] cli = ["dep:clap", "dep:anyhow"] postgres = ["sqlx", "kittybox-util/sqlx"] @@ -55,37 +59,36 @@ path = "examples/sql.rs" required-features = ["sqlparser"] [workspace] -members = [".", "./util", "./templates", "./indieauth", "./templates-neo", "./tower-watchdog"] +members = [".", "./util", "./templates", "./indieauth", "./tower-watchdog"] default-members = [".", "./util", "./templates", "./indieauth"] [workspace.dependencies] -axum = "0.8.1" -axum-core = "0.5.0" -chrono = { version = "0.4.39", features = ["serde"] } -clap = "4.5.23" -data-encoding = "2.6.0" +axum = "0.8.3" +axum-core = "0.5.2" +chrono = { version = "0.4.40", features = ["serde"] } +clap = "4.5.37" +data-encoding = "2.9.0" ellipse = "0.2.0" faker_rand = "0.1.1" futures = "0.3.31" futures-util = "0.3.31" -html = "0.6.3" -http = "1.2" +http = "1.3" include_dir = "0.7.4" libflate = "2.1.0" markup = "0.15.0" microformats = "0.14.0" rand = "0.8.5" -serde = { version = "1.0.217", features = ["derive"] } -serde_json = "1.0.134" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" serde_urlencoded = "0.7.1" serde_variant = "0.1.3" sha2 = "0.10.8" -sqlx = { version = "0.8.2", features = ["json"] } -thiserror = "2.0.9" -time = "0.3.37" -tokio = "1.42.0" +sqlx = { version = "0.8.5", features = ["json"] } +thiserror = "2.0.12" +time = "0.3.41" +tokio = "1.44.2" tokio-stream = "0.1.17" -tokio-util = "0.7.13" +tokio-util = "0.7.14" tower = "0.5.2" tower-layer = "0.3.3" tower-service = "0.3.3" @@ -96,7 +99,7 @@ tracing-subscriber = "0.3.19" tracing-test = "0.2.5" tracing-tree = "0.4.0" url = { version = "2.5.4", features = ["serde"] } -uuid = "1.11.0" +uuid = "1.16.0" walkdir = "2.5.0" [dependencies.kittybox-util] @@ -107,7 +110,7 @@ features = ["fs", "axum"] version = "0.1.0" path = "./templates" [dependencies.kittybox-indieauth] -version = "0.2.0" +version = "0.3.0" path = "./indieauth" features = ["axum"] @@ -116,54 +119,83 @@ features = ["axum"] [dev-dependencies] faker_rand = { workspace = true } rand = { workspace = true } -tempfile = "3.14.0" +tempfile = "3.19.1" tracing-test = { workspace = true } -wiremock = "0.6.2" +wiremock = "0.6.3" [dependencies] -anyhow = { version = "1.0.95", optional = true } +anyhow = { version = "1.0.98", optional = true } argon2 = { version = "0.5.3", features = ["std"] } axum = { workspace = true, features = ["multipart", "json", "form", "macros"] } -axum-extra = { version = "0.10.0", features = ["cookie", "cookie-signed", "typed-header"] } -bytes = "1.9.0" +axum-extra = { version = "0.10.1", features = [ + "cookie", + "cookie-signed", + "typed-header", +] } +bytes = "1.10.1" chrono = { workspace = true } clap = { workspace = true, features = ["derive"], optional = true } data-encoding = { workspace = true } -either = "1.13.0" +either = "1.15.0" futures = { workspace = true } futures-util = { workspace = true } html5ever = "=0.27.0" -http-cache-reqwest = { version = "0.15.0", default-features = false, features = ["manager-moka"] } -hyper = "1.5.2" +http-cache-reqwest = { version = "0.15.1", default-features = false, features = [ + "manager-moka", +] } +hyper = "1.6.0" lazy_static = "1.5.0" -listenfd = "1.0.1" -markdown = "1.0.0-alpha.21" +listenfd = "1.0.2" +markdown = "1.0.0-alpha.23" microformats = { workspace = true } mime = "0.3.17" newbase60 = "0.1.4" -prometheus = { version = "0.13.4", features = ["process"] } rand = { workspace = true } -redis = { version = "0.27.6", features = ["aio", "tokio-comp"], optional = true } +redis = { version = "0.27.6", features = [ + "aio", + "tokio-comp", +], optional = true } relative-path = "1.9.3" -reqwest = { version = "0.12.12", default-features = false, features = ["gzip", "brotli", "json", "stream"] } -reqwest-middleware = "0.4.0" +reqwest = { version = "0.12.15", default-features = false, features = [ + "gzip", + "brotli", + "json", + "stream", +] } +reqwest-middleware = "0.4.2" serde = { workspace = true } serde_json = { workspace = true } serde_urlencoded = { workspace = true } serde_variant = { workspace = true } sha2 = { workspace = true } -sqlparser = { version = "0.53.0", features = ["serde", "serde_json"], optional = true } -sqlx = { workspace = true, features = ["uuid", "chrono", "postgres", "runtime-tokio"], optional = true } +sqlparser = { version = "0.53.0", features = [ + "serde", + "serde_json", +], optional = true } +sqlx = { workspace = true, features = [ + "uuid", + "chrono", + "postgres", + "runtime-tokio", +], optional = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full", "tracing"] } tokio-stream = { workspace = true, features = ["time", "net"] } tokio-util = { workspace = true, features = ["io-util"] } tower = { workspace = true, features = ["tracing"] } -tower-http = { version = "0.6.2", features = ["trace", "cors", "catch-panic", "sensitive-headers", "set-header"] } +tower-http = { version = "0.6.2", features = [ + "trace", + "cors", + "catch-panic", + "sensitive-headers", + "set-header", +] } tracing = { workspace = true, features = [] } tracing-log = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } tracing-tree = { workspace = true } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } -webauthn = { version = "0.5.0", package = "webauthn-rs", features = ["danger-allow-state-serialisation"], optional = true } +webauthn = { version = "0.5.1", package = "webauthn-rs", features = [ + "danger-allow-state-serialisation", +], optional = true } diff --git a/build.rs b/build.rs index 05eca7a..5db39e0 100644 --- a/build.rs +++ b/build.rs @@ -22,9 +22,6 @@ fn main() { } let companion_in = std::path::Path::new("companion-lite"); for file in ["index.html", "style.css"] { - std::fs::copy( - companion_in.join(file), - &companion_out.join(file) - ).unwrap(); + std::fs::copy(companion_in.join(file), companion_out.join(file)).unwrap(); } } diff --git a/examples/password-hasher.rs b/examples/password-hasher.rs index 92de7f7..3c88a40 100644 --- a/examples/password-hasher.rs +++ b/examples/password-hasher.rs @@ -1,6 +1,9 @@ use std::io::Write; -use argon2::{Argon2, password_hash::{rand_core::OsRng, PasswordHasher, PasswordHash, PasswordVerifier, SaltString}}; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; fn main() -> std::io::Result<()> { eprint!("Type a password: "); @@ -15,19 +18,19 @@ fn main() -> std::io::Result<()> { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); //eprintln!("{}", password.trim()); - let password_hash = argon2.hash_password(password.trim().as_bytes(), &salt) + let password_hash = argon2 + .hash_password(password.trim().as_bytes(), &salt) .expect("Hashing a password should not error out") .serialize(); println!("{}", password_hash.as_str()); assert!(Argon2::default() - .verify_password( - password.trim().as_bytes(), - &PasswordHash::new(password_hash.as_str()) - .expect("Password hash should be valid") - ).is_ok() - ); + .verify_password( + password.trim().as_bytes(), + &PasswordHash::new(password_hash.as_str()).expect("Password hash should be valid") + ) + .is_ok()); Ok(()) } diff --git a/examples/sql.rs b/examples/sql.rs index 5d552da..59db1eb 100644 --- a/examples/sql.rs +++ b/examples/sql.rs @@ -15,8 +15,11 @@ fn sanitize(expr: &Expr) -> Result<(), Error> { match expr { Expr::Identifier(_) => Ok(()), Expr::CompoundIdentifier(_) => Ok(()), - Expr::JsonAccess { left, operator: _, right } => sanitize(left) - .and(sanitize(right)), + Expr::JsonAccess { + left, + operator: _, + right, + } => sanitize(left).and(sanitize(right)), Expr::CompositeAccess { expr, key: _ } => sanitize(expr), Expr::IsFalse(subexpr) => sanitize(subexpr), Expr::IsNotFalse(subexpr) => sanitize(subexpr), @@ -26,84 +29,180 @@ fn sanitize(expr: &Expr) -> Result<(), Error> { Expr::IsNotNull(subexpr) => sanitize(subexpr), Expr::IsUnknown(subexpr) => sanitize(subexpr), Expr::IsNotUnknown(subexpr) => sanitize(subexpr), - Expr::IsDistinctFrom(left, right) => sanitize(left) - .and(sanitize(right)), - Expr::IsNotDistinctFrom(left, right) => sanitize(left) - .and(sanitize(right)), - Expr::InList { expr, list, negated: _ } => sanitize(expr) - .and(list.iter().try_for_each(sanitize)), - Expr::InSubquery { expr: _, subquery, negated: _ } => Err(Error::SubqueryDetected(subquery.as_ref())), - Expr::InUnnest { expr, array_expr, negated: _ } => sanitize(expr).and(sanitize(array_expr)), - Expr::Between { expr, negated: _, low, high } => sanitize(expr) - .and(sanitize(low)) - .and(sanitize(high)), - Expr::BinaryOp { left, op: _, right } => sanitize(left) - .and(sanitize(right)), - Expr::Like { negated: _, expr, pattern, escape_char: _ } => sanitize(expr) - .and(sanitize(pattern)), - Expr::ILike { negated: _, expr, pattern, escape_char: _ } => sanitize(expr).and(sanitize(pattern)), - Expr::SimilarTo { negated: _, expr, pattern, escape_char: _ } => sanitize(expr).and(sanitize(pattern)), - Expr::RLike { negated: _, expr, pattern, regexp: _ } => sanitize(expr).and(sanitize(pattern)), - Expr::AnyOp { left, compare_op: _, right } => sanitize(left).and(sanitize(right)), - Expr::AllOp { left, compare_op: _, right } => sanitize(left).and(sanitize(right)), + Expr::IsDistinctFrom(left, right) => sanitize(left).and(sanitize(right)), + Expr::IsNotDistinctFrom(left, right) => sanitize(left).and(sanitize(right)), + Expr::InList { + expr, + list, + negated: _, + } => sanitize(expr).and(list.iter().try_for_each(sanitize)), + Expr::InSubquery { + expr: _, + subquery, + negated: _, + } => Err(Error::SubqueryDetected(subquery.as_ref())), + Expr::InUnnest { + expr, + array_expr, + negated: _, + } => sanitize(expr).and(sanitize(array_expr)), + Expr::Between { + expr, + negated: _, + low, + high, + } => sanitize(expr).and(sanitize(low)).and(sanitize(high)), + Expr::BinaryOp { left, op: _, right } => sanitize(left).and(sanitize(right)), + Expr::Like { + negated: _, + expr, + pattern, + escape_char: _, + } => sanitize(expr).and(sanitize(pattern)), + Expr::ILike { + negated: _, + expr, + pattern, + escape_char: _, + } => sanitize(expr).and(sanitize(pattern)), + Expr::SimilarTo { + negated: _, + expr, + pattern, + escape_char: _, + } => sanitize(expr).and(sanitize(pattern)), + Expr::RLike { + negated: _, + expr, + pattern, + regexp: _, + } => sanitize(expr).and(sanitize(pattern)), + Expr::AnyOp { + left, + compare_op: _, + right, + } => sanitize(left).and(sanitize(right)), + Expr::AllOp { + left, + compare_op: _, + right, + } => sanitize(left).and(sanitize(right)), Expr::UnaryOp { op: _, expr } => sanitize(expr), - Expr::Convert { expr, data_type: _, charset: _, target_before_value: _ } => sanitize(expr), - Expr::Cast { expr, data_type: _, format: _ } => sanitize(expr), - Expr::TryCast { expr, data_type: _, format: _ } => sanitize(expr), - Expr::SafeCast { expr, data_type: _, format: _ } => sanitize(expr), - Expr::AtTimeZone { timestamp, time_zone: _ } => sanitize(timestamp), + Expr::Convert { + expr, + data_type: _, + charset: _, + target_before_value: _, + } => sanitize(expr), + Expr::Cast { + expr, + data_type: _, + format: _, + } => sanitize(expr), + Expr::TryCast { + expr, + data_type: _, + format: _, + } => sanitize(expr), + Expr::SafeCast { + expr, + data_type: _, + format: _, + } => sanitize(expr), + Expr::AtTimeZone { + timestamp, + time_zone: _, + } => sanitize(timestamp), Expr::Extract { field: _, expr } => sanitize(expr), Expr::Ceil { expr, field: _ } => sanitize(expr), Expr::Floor { expr, field: _ } => sanitize(expr), Expr::Position { expr, r#in } => sanitize(expr).and(sanitize(r#in)), - Expr::Substring { expr, substring_from, substring_for, special: _ } => sanitize(expr) + Expr::Substring { + expr, + substring_from, + substring_for, + special: _, + } => sanitize(expr) .and(substring_from.as_deref().map(sanitize).unwrap_or(Ok(()))) .and(substring_for.as_deref().map(sanitize).unwrap_or(Ok(()))), - Expr::Trim { expr, trim_where: _, trim_what, trim_characters } => sanitize(expr) + Expr::Trim { + expr, + trim_where: _, + trim_what, + trim_characters, + } => sanitize(expr) .and(trim_what.as_deref().map(sanitize).unwrap_or(Ok(()))) .and( trim_characters .as_ref() .map(|v| v.iter()) .map(|mut iter| iter.try_for_each(sanitize)) - .unwrap_or(Ok(())) + .unwrap_or(Ok(())), ), - Expr::Overlay { expr, overlay_what, overlay_from, overlay_for } => sanitize(expr) + Expr::Overlay { + expr, + overlay_what, + overlay_from, + overlay_for, + } => sanitize(expr) .and(sanitize(overlay_what)) .and(sanitize(overlay_from)) .and(overlay_for.as_deref().map(sanitize).unwrap_or(Ok(()))), Expr::Collate { expr, collation: _ } => sanitize(expr), Expr::Nested(subexpr) => sanitize(subexpr), Expr::Value(_) => Ok(()), - Expr::IntroducedString { introducer: _, value: _ } => Ok(()), - Expr::TypedString { data_type: _, value: _ } => Ok(()), - Expr::MapAccess { column, keys } => sanitize(column).and(keys.iter().try_for_each(sanitize)), + Expr::IntroducedString { + introducer: _, + value: _, + } => Ok(()), + Expr::TypedString { + data_type: _, + value: _, + } => Ok(()), + Expr::MapAccess { column, keys } => { + sanitize(column).and(keys.iter().try_for_each(sanitize)) + } Expr::Function(func) => Err(Error::FunctionCallDetected(func)), - Expr::AggregateExpressionWithFilter { expr, filter } => sanitize(expr).and(sanitize(filter)), - Expr::Case { operand, conditions, results, else_result } => conditions.iter() + Expr::AggregateExpressionWithFilter { expr, filter } => { + sanitize(expr).and(sanitize(filter)) + } + Expr::Case { + operand, + conditions, + results, + else_result, + } => conditions + .iter() .chain(results) .chain(operand.iter().map(std::borrow::Borrow::borrow)) .chain(else_result.iter().map(std::borrow::Borrow::borrow)) .try_for_each(sanitize), - Expr::Exists { subquery, negated: _ } => Err(Error::SubqueryDetected(subquery)), + Expr::Exists { + subquery, + negated: _, + } => Err(Error::SubqueryDetected(subquery)), Expr::Subquery(subquery) => Err(Error::SubqueryDetected(subquery.as_ref())), Expr::ArraySubquery(subquery) => Err(Error::SubqueryDetected(subquery.as_ref())), Expr::ListAgg(agg) => sanitize(&agg.expr), Expr::ArrayAgg(agg) => sanitize(&agg.expr), - Expr::GroupingSets(sets) => sets.iter() + Expr::GroupingSets(sets) => sets + .iter() .map(|i| i.iter()) .try_for_each(|mut si| si.try_for_each(sanitize)), - Expr::Cube(cube) => cube.iter() + Expr::Cube(cube) => cube + .iter() .map(|i| i.iter()) .try_for_each(|mut si| si.try_for_each(sanitize)), - Expr::Rollup(rollup) => rollup.iter() + Expr::Rollup(rollup) => rollup + .iter() .map(|i| i.iter()) .try_for_each(|mut si| si.try_for_each(sanitize)), Expr::Tuple(tuple) => tuple.iter().try_for_each(sanitize), Expr::Struct { values, fields: _ } => values.iter().try_for_each(sanitize), Expr::Named { expr, name: _ } => sanitize(expr), - Expr::ArrayIndex { obj, indexes } => sanitize(obj) - .and(indexes.iter().try_for_each(sanitize)), + Expr::ArrayIndex { obj, indexes } => { + sanitize(obj).and(indexes.iter().try_for_each(sanitize)) + } Expr::Array(array) => array.elem.iter().try_for_each(sanitize), Expr::Interval(interval) => sanitize(&interval.value), Expr::MatchAgainst { .. } => Ok(()), @@ -115,7 +214,8 @@ fn sanitize(expr: &Expr) -> Result<(), Error> { fn main() -> Result<(), sqlparser::parser::ParserError> { let query = std::env::args().skip(1).take(1).next().unwrap(); - static DIALECT: sqlparser::dialect::PostgreSqlDialect = sqlparser::dialect::PostgreSqlDialect {}; + static DIALECT: sqlparser::dialect::PostgreSqlDialect = + sqlparser::dialect::PostgreSqlDialect {}; let parser: sqlparser::parser::Parser<'static> = sqlparser::parser::Parser::new(&DIALECT); let expr = parser.try_with_sql(&query)?.parse_expr()?; @@ -125,6 +225,6 @@ fn main() -> Result<(), sqlparser::parser::ParserError> { eprintln!("{}", err); } } - + Ok(()) } diff --git a/flake.lock b/flake.lock index bf7a454..5667933 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1734808813, - "narHash": "sha256-3aH/0Y6ajIlfy7j52FGZ+s4icVX0oHhqBzRdlOeztqg=", + "lastModified": 1745022865, + "narHash": "sha256-tXL4qUlyYZEGOHUKUWjmmcvJjjLQ+4U38lPWSc8Cgdo=", "owner": "ipetkov", "repo": "crane", - "rev": "72e2d02dbac80c8c86bf6bf3e785536acf8ee926", + "rev": "25ca4c50039d91ad88cc0b8feacb9ad7f748dedf", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f820e51..81eee80 100644 --- a/flake.nix +++ b/flake.nix @@ -20,14 +20,14 @@ overlays = [ rust-overlay.overlays.default ]; localSystem = { inherit system; }; }; + # NOTE: `pkgs` here must match `pkgs` used for `callPackage` to ensure + # cross-compilation works. Crane sets the requisite variables automatically. crane' = crane.mkLib pkgs; cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); crane-msrv' = crane'.overrideToolchain (p: p.rust-bin.stable."${cargoToml.package.rust-version}".default); kittybox = pkgs.callPackage ./kittybox.nix { - # TODO: this may break cross-compilation. It may be better to - # inject it as an overlay. However, I am unsure whether Crane - # can recognize it's being passed a cross-compilation set. + # NOTE: See above re: cross-compilation. crane = crane'; nixosTests = { diff --git a/indieauth/Cargo.toml b/indieauth/Cargo.toml index 3bc3864..9213a51 100644 --- a/indieauth/Cargo.toml +++ b/indieauth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kittybox-indieauth" -version = "0.2.0" +version = "0.3.3" edition = "2021" [features] @@ -9,7 +9,7 @@ axum = ["dep:axum-core", "dep:serde_json", "dep:http"] [dev-dependencies] serde_json = { workspace = true } # A JSON serialization file format -serde_urlencoded = { workspace = true } # `x-www-form-urlencoded` meets Serde +serde_urlencoded = { workspace = true } # `x-www-form-urlencoded` meets Serde [dependencies] axum-core = { workspace = true, optional = true } diff --git a/indieauth/src/lib.rs b/indieauth/src/lib.rs index b3ec098..b10fd0e 100644 --- a/indieauth/src/lib.rs +++ b/indieauth/src/lib.rs @@ -20,13 +20,13 @@ //! [`axum`]: https://github.com/tokio-rs/axum use std::borrow::Cow; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use url::Url; mod scopes; pub use self::scopes::{Scope, Scopes}; mod pkce; -pub use self::pkce::{PKCEMethod, PKCEVerifier, PKCEChallenge}; +pub use self::pkce::{PKCEChallenge, PKCEMethod, PKCEVerifier}; // Re-export rand crate just to be sure. pub use rand; @@ -34,23 +34,24 @@ pub use rand; /// Authentication methods supported by the introspection endpoint. /// Note that authentication at the introspection endpoint is /// mandatory. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] pub enum IntrospectionEndpointAuthMethod { /// `Authorization` header with a `Bearer` token. + #[serde(rename_all = "PascalCase")] Bearer, /// A token passed as part of a POST request. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] ClientSecretPost, /// Username and password passed using HTTP Basic authentication. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] ClientSecretBasic, /// TLS client auth with a certificate signed by a valid CA. - #[serde(rename = "snake_case")] + #[serde(rename_all = "snake_case")] TlsClientAuth, /// TLS client auth with a self-signed certificate. - #[serde(rename = "snake_case")] - SelfSignedTlsClientAuth + #[serde(rename_all = "snake_case")] + SelfSignedTlsClientAuth, } /// Authentication methods supported by the revocation endpoint. @@ -60,17 +61,17 @@ pub enum IntrospectionEndpointAuthMethod { /// authentication is neccesary to protect tokens. A well-intentioned /// person discovering a leaked token could quickly revoke it without /// disturbing anyone. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum RevocationEndpointAuthMethod { /// No authentication is required to access an endpoint declaring /// this value. - None + None, } /// The response types supported by the authorization endpoint. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ResponseType { /// An authorization code will be issued if this response type is @@ -82,7 +83,7 @@ pub enum ResponseType { /// This response type requires a valid access token. /// /// [AutoAuth spec]: https://github.com/sknebel/AutoAuth/blob/master/AutoAuth.md#allowing-external-clients-to-obtain-tokens - ExternalToken + ExternalToken, } // TODO serde_variant impl ResponseType { @@ -100,7 +101,7 @@ impl ResponseType { /// This type is strictly for usage in the [`Metadata`] response. For /// grant requests and responses, see [`GrantRequest`] and /// [`GrantResponse`]. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GrantType { /// The authorization code grant, allowing to exchange an @@ -110,7 +111,7 @@ pub enum GrantType { /// The refresh token grant, allowing to exchange a refresh token /// for a fresh access token and a new refresh token, to /// facilitate long-term access. - RefreshToken + RefreshToken, } /// OAuth 2.0 Authorization Server Metadata in application to the IndieAuth protocol. @@ -222,7 +223,7 @@ pub struct Metadata { /// registration. #[serde(skip_serializing_if = "ref_identity")] #[serde(default = "Default::default")] - pub client_id_metadata_document_supported: bool + pub client_id_metadata_document_supported: bool, } impl std::fmt::Debug for Metadata { @@ -232,31 +233,59 @@ impl std::fmt::Debug for Metadata { .field("authorization_endpoint", &self.issuer.as_str()) .field("token_endpoint", &self.issuer.as_str()) .field("introspection_endpoint", &self.issuer.as_str()) - .field("introspection_endpoint_auth_methods_supported", &self.introspection_endpoint_auth_methods_supported) - .field("revocation_endpoint", &self.revocation_endpoint.as_ref().map(Url::as_str)) - .field("revocation_endpoint_auth_methods_supported", &self.revocation_endpoint_auth_methods_supported) + .field( + "introspection_endpoint_auth_methods_supported", + &self.introspection_endpoint_auth_methods_supported, + ) + .field( + "revocation_endpoint", + &self.revocation_endpoint.as_ref().map(Url::as_str), + ) + .field( + "revocation_endpoint_auth_methods_supported", + &self.revocation_endpoint_auth_methods_supported, + ) .field("scopes_supported", &self.scopes_supported) .field("response_types_supported", &self.response_types_supported) .field("grant_types_supported", &self.grant_types_supported) - .field("service_documentation", &self.service_documentation.as_ref().map(Url::as_str)) - .field("code_challenge_methods_supported", &self.code_challenge_methods_supported) - .field("authorization_response_iss_parameter_supported", &self.authorization_response_iss_parameter_supported) - .field("userinfo_endpoint", &self.userinfo_endpoint.as_ref().map(Url::as_str)) - .field("client_id_metadata_document_supported", &self.client_id_metadata_document_supported) + .field( + "service_documentation", + &self.service_documentation.as_ref().map(Url::as_str), + ) + .field( + "code_challenge_methods_supported", + &self.code_challenge_methods_supported, + ) + .field( + "authorization_response_iss_parameter_supported", + &self.authorization_response_iss_parameter_supported, + ) + .field( + "userinfo_endpoint", + &self.userinfo_endpoint.as_ref().map(Url::as_str), + ) + .field( + "client_id_metadata_document_supported", + &self.client_id_metadata_document_supported, + ) .finish() } } -fn ref_identity(v: &bool) -> bool { *v } +fn ref_identity(v: &bool) -> bool { + *v +} #[cfg(feature = "axum")] impl axum_core::response::IntoResponse for Metadata { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -308,24 +337,37 @@ pub struct ClientMetadata { pub software_version: Option<Cow<'static, str>>, /// URI for the homepage of this client's owners #[serde(skip_serializing_if = "Option::is_none")] - pub homepage_uri: Option<Url> + pub homepage_uri: Option<Url>, +} + +/// Error that occurs when creating [`ClientMetadata`] with mismatched `client_id` and `client_uri`. +#[derive(Debug)] +pub struct ClientIdMismatch; + +impl std::error::Error for ClientIdMismatch {} +impl std::fmt::Display for ClientIdMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "client_id must be a prefix of client_uri") + } } impl ClientMetadata { - /// Create a new [`ClientMetadata`] with all the optional fields - /// omitted. + /// Create a new [`ClientMetadata`] with all the optional fields omitted. /// /// # Errors /// - /// Returns `()` if the `client_uri` is not a prefix of - /// `client_id` as required by the IndieAuth spec. - pub fn new(client_id: url::Url, client_uri: url::Url) -> Result<Self, ()> { - if client_id.as_str().as_bytes()[..client_uri.as_str().len()] != *client_uri.as_str().as_bytes() { - return Err(()); + /// Returns `()` if the `client_uri` is not a prefix of `client_id` as required by the IndieAuth + /// spec. + pub fn new(client_id: url::Url, client_uri: url::Url) -> Result<Self, ClientIdMismatch> { + if client_id.as_str().as_bytes()[..client_uri.as_str().len()] + != *client_uri.as_str().as_bytes() + { + return Err(ClientIdMismatch); } Ok(Self { - client_id, client_uri, + client_id, + client_uri, client_name: None, logo_uri: None, redirect_uris: None, @@ -355,14 +397,15 @@ impl axum_core::response::IntoResponse for ClientMetadata { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } - /// User profile to be returned from the userinfo endpoint and when /// the `profile` scope was requested. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -379,7 +422,7 @@ pub struct Profile { /// User's email, if they've chosen to reveal it. This is guarded /// by the `email` scope. #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option<String> + pub email: Option<String>, } #[cfg(feature = "axum")] @@ -387,9 +430,11 @@ impl axum_core::response::IntoResponse for Profile { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -414,13 +459,13 @@ impl State { /// Generate a random state string of 128 bytes in length, using /// the provided random number generator. pub fn from_rng(rng: &mut (impl rand::CryptoRng + rand::Rng)) -> Self { - use rand::{Rng, distributions::Alphanumeric}; + use rand::{distributions::Alphanumeric, Rng}; - let bytes = rng.sample_iter(&Alphanumeric) + let bytes = rng + .sample_iter(&Alphanumeric) .take(128) .collect::<Vec<u8>>(); Self(String::from_utf8(bytes).unwrap()) - } } impl AsRef<str> for State { @@ -503,21 +548,23 @@ impl AuthorizationRequest { ("response_type", Cow::Borrowed(self.response_type.as_str())), ("client_id", Cow::Borrowed(self.client_id.as_str())), ("redirect_uri", Cow::Borrowed(self.redirect_uri.as_str())), - ("code_challenge", Cow::Borrowed(self.code_challenge.as_str())), - ("code_challenge_method", Cow::Borrowed(self.code_challenge.method().as_str())), - ("state", Cow::Borrowed(self.state.as_ref())) + ( + "code_challenge", + Cow::Borrowed(self.code_challenge.as_str()), + ), + ( + "code_challenge_method", + Cow::Borrowed(self.code_challenge.method().as_str()), + ), + ("state", Cow::Borrowed(self.state.as_ref())), ]; if let Some(ref scope) = self.scope { - v.push( - ("scope", Cow::Owned(scope.to_string())) - ); + v.push(("scope", Cow::Owned(scope.to_string()))); } if let Some(ref me) = self.me { - v.push( - ("me", Cow::Borrowed(me.as_str())) - ); + v.push(("me", Cow::Borrowed(me.as_str()))); } v @@ -550,17 +597,22 @@ pub struct AutoAuthRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutoAuthCallbackData { state: State, - callback_url: Url + callback_url: Url, } #[inline(always)] -fn deserialize_secs<'de, D: serde::de::Deserializer<'de>>(d: D) -> Result<std::time::Duration, D::Error> { +fn deserialize_secs<'de, D: serde::de::Deserializer<'de>>( + d: D, +) -> Result<std::time::Duration, D::Error> { use serde::Deserialize; Ok(std::time::Duration::from_secs(u64::deserialize(d)?)) } #[inline(always)] -fn serialize_secs<S: serde::ser::Serializer>(d: &std::time::Duration, s: S) -> Result<S::Ok, S::Error> { +fn serialize_secs<S: serde::ser::Serializer>( + d: &std::time::Duration, + s: S, +) -> Result<S::Ok, S::Error> { s.serialize_u64(std::time::Duration::as_secs(d)) } @@ -570,7 +622,7 @@ pub struct AutoAuthPollingResponse { request_id: State, #[serde(serialize_with = "serialize_secs")] #[serde(deserialize_with = "deserialize_secs")] - interval: std::time::Duration + interval: std::time::Duration, } /// The authorization response that must be appended to the @@ -602,10 +654,9 @@ pub struct AuthorizationResponse { /// authorization server. /// /// [oauth2-iss]: https://www.ietf.org/archive/id/draft-ietf-oauth-iss-auth-resp-02.html - pub iss: Url + pub iss: Url, } - /// A special grant request that is used in the AutoAuth ceremony. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AutoAuthCodeGrant { @@ -628,7 +679,7 @@ pub struct AutoAuthCodeGrant { callback_url: Url, /// The user's URL. Will be used to confirm the authorization /// endpoint's authority. - me: Url + me: Url, } /// A grant request that continues the IndieAuth ceremony. @@ -647,7 +698,7 @@ pub enum GrantRequest { redirect_uri: Url, /// The PKCE code verifier that was used to create the code /// challenge. - code_verifier: PKCEVerifier + code_verifier: PKCEVerifier, }, /// Use a refresh token to get a fresh access token and a new /// matching refresh token. @@ -662,8 +713,8 @@ pub enum GrantRequest { /// /// This cannot be used to gain new scopes -- you need to /// start over if you need new scopes from the user. - scope: Option<Scopes> - } + scope: Option<Scopes>, + }, } /// Token type, as described in [RFC6749][]. @@ -677,7 +728,7 @@ pub enum TokenType { /// IndieAuth uses. /// /// [RFC6750]: https://www.rfc-editor.org/rfc/rfc6750 - Bearer + Bearer, } /// The response to a successful [`GrantRequest`]. @@ -714,14 +765,14 @@ pub enum GrantResponse { profile: Option<Profile>, /// The refresh token, if it was issued. #[serde(skip_serializing_if = "Option::is_none")] - refresh_token: Option<String> + refresh_token: Option<String>, }, /// A profile URL response, that only contains the profile URL and /// the profile, if it was requested. /// /// This is suitable for confirming the identity of the user, but /// no more than that. - ProfileUrl(ProfileUrl) + ProfileUrl(ProfileUrl), } /// The contents of a profile URL response. @@ -731,7 +782,7 @@ pub struct ProfileUrl { pub me: Url, /// The user's profile information, if it was requested. #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option<Profile> + pub profile: Option<Profile>, } #[cfg(feature = "axum")] @@ -739,12 +790,15 @@ impl axum_core::response::IntoResponse for GrantResponse { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json"), - ("Cache-Control", "no-store"), - ("Pragma", "no-cache") - ], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::OK, + [ + ("Content-Type", "application/json"), + ("Cache-Control", "no-store"), + ("Pragma", "no-cache"), + ], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -758,7 +812,7 @@ impl axum_core::response::IntoResponse for GrantResponse { pub enum RequestMaybeAuthorizationEndpoint { Authorization(AuthorizationRequest), Grant(GrantRequest), - AutoAuth(AutoAuthCodeGrant) + AutoAuth(AutoAuthCodeGrant), } /// A token introspection request that can be handled by the token @@ -770,7 +824,7 @@ pub enum RequestMaybeAuthorizationEndpoint { #[derive(Debug, Serialize, Deserialize)] pub struct TokenIntrospectionRequest { /// The token for which data was requested. - pub token: String + pub token: String, } /// Data for a token that will be returned by the introspection @@ -792,7 +846,7 @@ pub struct TokenData { /// The issue date, represented in the same format as the /// [`exp`][TokenData::exp] field. #[serde(skip_serializing_if = "Option::is_none")] - pub iat: Option<u64> + pub iat: Option<u64>, } impl TokenData { @@ -801,24 +855,25 @@ impl TokenData { use std::time::{Duration, SystemTime, UNIX_EPOCH}; self.exp - .map(|exp| SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_secs() >= exp) + .map(|exp| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() + >= exp + }) .unwrap_or_default() } /// Return a timestamp at which the token is not considered valid anymore. pub fn expires_at(&self) -> Option<std::time::SystemTime> { - self.exp.map(|time| { - std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) - }) + self.exp + .map(|time| std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)) } /// Return a timestamp describing when the token was issued. pub fn issued_at(&self) -> Option<std::time::SystemTime> { - self.iat.map(|time| { - std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) - }) + self.iat + .map(|time| std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)) } /// Check if a certain scope is allowed for this token. @@ -841,18 +896,24 @@ pub struct TokenIntrospectionResponse { active: bool, #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] - data: Option<TokenData> + data: Option<TokenData>, } // These wrappers and impls should take care of making use of this // type as painless as possible. impl TokenIntrospectionResponse { /// Indicate that this token is not valid. pub fn inactive() -> Self { - Self { active: false, data: None } + Self { + active: false, + data: None, + } } /// Indicate that this token is valid, and provide data about it. pub fn active(data: TokenData) -> Self { - Self { active: true, data: Some(data) } + Self { + active: true, + data: Some(data), + } } /// Check if the endpoint reports this token as valid. pub fn is_active(&self) -> bool { @@ -862,7 +923,7 @@ impl TokenIntrospectionResponse { /// Get data contained in the response, if the token is valid. pub fn data(&self) -> Option<&TokenData> { if !self.active { - return None + return None; } self.data.as_ref() } @@ -874,7 +935,10 @@ impl Default for TokenIntrospectionResponse { } impl From<Option<TokenData>> for TokenIntrospectionResponse { fn from(data: Option<TokenData>) -> Self { - Self { active: data.is_some(), data } + Self { + active: data.is_some(), + data, + } } } impl From<TokenIntrospectionResponse> for Option<TokenData> { @@ -888,9 +952,11 @@ impl axum_core::response::IntoResponse for TokenIntrospectionResponse { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::OK, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::OK, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -900,7 +966,7 @@ impl axum_core::response::IntoResponse for TokenIntrospectionResponse { #[derive(Debug, Serialize, Deserialize)] pub struct TokenRevocationRequest { /// The token that needs to be revoked in case it is valid. - pub token: String + pub token: String, } /// Types of errors that a resource server (IndieAuth consumer) can @@ -961,7 +1027,6 @@ pub enum ErrorKind { /// AutoAuth/OAuth2 Device Flow: Access was denied by the /// authorization endpoint. AccessDenied, - } // TODO consider relying on serde_variant for these conversions impl AsRef<str> for ErrorKind { @@ -997,13 +1062,15 @@ pub struct Error { pub msg: Option<String>, /// An URL to documentation describing what went wrong and how to /// fix it. - pub error_uri: Option<url::Url> + pub error_uri: Option<url::Url>, } impl From<ErrorKind> for Error { fn from(kind: ErrorKind) -> Error { Error { - kind, msg: None, error_uri: None + kind, + msg: None, + error_uri: None, } } } @@ -1029,9 +1096,11 @@ impl axum_core::response::IntoResponse for self::Error { fn into_response(self) -> axum_core::response::Response { use http::StatusCode; - (StatusCode::BAD_REQUEST, - [("Content-Type", "application/json")], - serde_json::to_vec(&self).unwrap()) + ( + StatusCode::BAD_REQUEST, + [("Content-Type", "application/json")], + serde_json::to_vec(&self).unwrap(), + ) .into_response() } } @@ -1044,17 +1113,23 @@ mod tests { fn test_serialize_deserialize_grant_request() { let authorization_code: GrantRequest = GrantRequest::AuthorizationCode { client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), - redirect_uri: "https://kittybox.fireburn.ru/.kittybox/login/redirect".parse().unwrap(), + redirect_uri: "https://kittybox.fireburn.ru/.kittybox/login/redirect" + .parse() + .unwrap(), code_verifier: PKCEVerifier("helloworld".to_string()), - code: "hithere".to_owned() + code: "hithere".to_owned(), }; let serialized = serde_urlencoded::to_string([ ("grant_type", "authorization_code"), ("code", "hithere"), ("client_id", "https://kittybox.fireburn.ru/"), - ("redirect_uri", "https://kittybox.fireburn.ru/.kittybox/login/redirect"), + ( + "redirect_uri", + "https://kittybox.fireburn.ru/.kittybox/login/redirect", + ), ("code_verifier", "helloworld"), - ]).unwrap(); + ]) + .unwrap(); let deserialized = serde_urlencoded::from_str(&serialized).unwrap(); diff --git a/indieauth/src/pkce.rs b/indieauth/src/pkce.rs index 8dcf9b1..6233016 100644 --- a/indieauth/src/pkce.rs +++ b/indieauth/src/pkce.rs @@ -1,6 +1,6 @@ -use serde::{Serialize, Deserialize}; -use sha2::{Sha256, Digest}; use data_encoding::BASE64URL; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// Methods to use for PKCE challenges. #[derive(PartialEq, Eq, Copy, Clone, Debug, Serialize, Deserialize, Default)] @@ -10,7 +10,7 @@ pub enum PKCEMethod { S256, /// Plain string by itself. Please don't use this. #[serde(rename = "snake_case")] - Plain + Plain, } impl PKCEMethod { @@ -18,7 +18,7 @@ impl PKCEMethod { pub fn as_str(&self) -> &'static str { match self { PKCEMethod::S256 => "S256", - PKCEMethod::Plain => "plain" + PKCEMethod::Plain => "plain", } } } @@ -57,7 +57,7 @@ impl PKCEVerifier { /// Generate a new PKCE string of 128 bytes in length, using /// the provided random number generator. pub fn from_rng(rng: &mut (impl rand::CryptoRng + rand::Rng)) -> Self { - use rand::{Rng, distributions::Alphanumeric}; + use rand::{distributions::Alphanumeric, Rng}; let bytes = rng .sample_iter(&Alphanumeric) @@ -65,7 +65,6 @@ impl PKCEVerifier { .collect::<Vec<u8>>(); Self(String::from_utf8(bytes).unwrap()) } - } /// A PKCE challenge as described in [RFC7636]. @@ -75,7 +74,7 @@ impl PKCEVerifier { pub struct PKCEChallenge { code_challenge: String, #[serde(rename = "code_challenge_method")] - method: PKCEMethod + method: PKCEMethod, } impl PKCEChallenge { @@ -92,10 +91,10 @@ impl PKCEChallenge { challenge.retain(|c| c != '='); challenge - }, + } PKCEMethod::Plain => code_verifier.to_string(), }, - method + method, } } @@ -130,17 +129,21 @@ impl PKCEChallenge { #[cfg(test)] mod tests { - use super::{PKCEMethod, PKCEVerifier, PKCEChallenge}; + use super::{PKCEChallenge, PKCEMethod, PKCEVerifier}; #[test] /// A snapshot test generated using [Aaron Parecki's PKCE /// tools](https://example-app.com/pkce) that checks for a /// conforming challenge. fn test_pkce_challenge_verification() { - let verifier = PKCEVerifier("ec03310e4e90f7bc988af05384060c3c1afeae4bb4d0f648c5c06b63".to_owned()); + let verifier = + PKCEVerifier("ec03310e4e90f7bc988af05384060c3c1afeae4bb4d0f648c5c06b63".to_owned()); let challenge = PKCEChallenge::new(&verifier, PKCEMethod::S256); - assert_eq!(challenge.as_str(), "aB8OG20Rh8UoQ9gFhI0YvPkx4dDW2MBspBKGXL6j6Wg"); + assert_eq!( + challenge.as_str(), + "aB8OG20Rh8UoQ9gFhI0YvPkx4dDW2MBspBKGXL6j6Wg" + ); } } diff --git a/indieauth/src/scopes.rs b/indieauth/src/scopes.rs index 02ee8dc..7333b5b 100644 --- a/indieauth/src/scopes.rs +++ b/indieauth/src/scopes.rs @@ -1,12 +1,8 @@ use std::str::FromStr; use serde::{ - Serialize, Serializer, - Deserialize, - de::{ - Deserializer, Visitor, - Error as DeserializeError - } + de::{Deserializer, Error as DeserializeError, Visitor}, + Deserialize, Serialize, Serializer, }; /// Various scopes that can be requested through IndieAuth. @@ -36,7 +32,7 @@ pub enum Scope { /// Allows to receive email in the profile information. Email, /// Custom scope not included above. - Custom(String) + Custom(String), } impl Scope { /// Create a custom scope from a string slice. @@ -61,25 +57,25 @@ impl AsRef<str> for Scope { Channels => "channels", Profile => "profile", Email => "email", - Custom(s) => s.as_ref() + Custom(s) => s.as_ref(), } } } impl From<&str> for Scope { fn from(scope: &str) -> Self { match scope { - "create" => Scope::Create, - "update" => Scope::Update, - "delete" => Scope::Delete, - "media" => Scope::Media, - "read" => Scope::Read, - "follow" => Scope::Follow, - "mute" => Scope::Mute, - "block" => Scope::Block, + "create" => Scope::Create, + "update" => Scope::Update, + "delete" => Scope::Delete, + "media" => Scope::Media, + "read" => Scope::Read, + "follow" => Scope::Follow, + "mute" => Scope::Mute, + "block" => Scope::Block, "channels" => Scope::Channels, - "profile" => Scope::Profile, - "email" => Scope::Email, - other => Scope::custom(other) + "profile" => Scope::Profile, + "email" => Scope::Email, + other => Scope::custom(other), } } } @@ -106,7 +102,8 @@ impl Scopes { } /// Ensure all of the requested scopes are in the list. pub fn has_all(&self, scopes: &[Scope]) -> bool { - scopes.iter() + scopes + .iter() .map(|s1| self.iter().any(|s2| s1 == s2)) .all(|s| s) } @@ -114,6 +111,19 @@ impl Scopes { pub fn iter(&self) -> std::slice::Iter<'_, Scope> { self.0.iter() } + + /// Count scopes requested by the application. + pub fn len(&self) -> usize { + self.0.len() + } + + /// See if the application requested any scopes. + /// + /// Some older applications forget to request scopes. This may be used to force a default scope. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } impl AsRef<[Scope]> for Scopes { fn as_ref(&self) -> &[Scope] { @@ -123,8 +133,7 @@ impl AsRef<[Scope]> for Scopes { impl std::fmt::Display for Scopes { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut iter = self.0.iter() - .peekable(); + let mut iter = self.0.iter().peekable(); while let Some(scope) = iter.next() { f.write_str(scope.as_ref())?; if iter.peek().is_some() { @@ -139,20 +148,24 @@ impl FromStr for Scopes { type Err = std::convert::Infallible; fn from_str(value: &str) -> Result<Self, Self::Err> { - Ok(Self(value.split_ascii_whitespace() + Ok(Self( + value + .split_ascii_whitespace() .map(Scope::from) - .collect::<Vec<Scope>>())) + .collect::<Vec<Scope>>(), + )) } } impl Serialize for Scopes { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where - S: Serializer + S: Serializer, { serializer.serialize_str(&self.to_string()) } } struct ScopeVisitor; +#[allow(clippy::needless_lifetimes, reason = "serde idiom")] impl<'de> Visitor<'de> for ScopeVisitor { type Value = Scopes; @@ -162,16 +175,15 @@ impl<'de> Visitor<'de> for ScopeVisitor { fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> where - E: DeserializeError + E: DeserializeError, { Ok(Scopes::from_str(value).unwrap()) } } impl<'de> Deserialize<'de> for Scopes { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where - D: Deserializer<'de> + D: Deserializer<'de>, { deserializer.deserialize_str(ScopeVisitor) } @@ -184,29 +196,31 @@ mod tests { #[test] fn test_serde_vec_scope() { let scopes = vec![ - Scope::Create, Scope::Update, Scope::Delete, + Scope::Create, + Scope::Update, + Scope::Delete, Scope::Media, - Scope::custom("kittybox_internal_access") + Scope::custom("kittybox_internal_access"), ]; - let scope_serialized = serde_json::to_value( - Scopes::new(scopes.clone()) - ).unwrap(); + let scope_serialized = serde_json::to_value(Scopes::new(scopes.clone())).unwrap(); let scope_str = scope_serialized.as_str().unwrap(); - assert_eq!(scope_str, "create update delete media kittybox_internal_access"); + assert_eq!( + scope_str, + "create update delete media kittybox_internal_access" + ); - assert!(serde_json::from_value::<Scopes>(scope_serialized).unwrap().has_all(&scopes)) + assert!(serde_json::from_value::<Scopes>(scope_serialized) + .unwrap() + .has_all(&scopes)) } #[test] fn test_scope_has_all() { - let scopes = Scopes(vec![ - Scope::Create, Scope::Update, Scope::custom("draft") - ]); + let scopes = Scopes(vec![Scope::Create, Scope::Update, Scope::custom("draft")]); assert!(scopes.has_all(&[Scope::Create, Scope::custom("draft")])); assert!(!scopes.has_all(&[Scope::Read, Scope::custom("kittybox_internal_access")])); } - } diff --git a/kittybox.1 b/kittybox.1 new file mode 100644 index 0000000..a00adfe --- /dev/null +++ b/kittybox.1 @@ -0,0 +1,97 @@ +.TH KITTYBOX 1 "" https://kittybox.fireburn.ru/ +.SH NAME +kittybox \- a CMS using IndieWeb technologies +.SH SYNOPSIS +.SY kittybox +.YS +.SH DESCRIPTION +.P +.B kittybox +is a full-featured CMS for a personal website which is able to use various +storage backends to store website content. +.P +It is most suitable for a personal blog, though it probably is capable of being +used for other purposes. +.SH ENVIRONMENT +.PP +.I $BACKEND_URI +.RS 4 +The URI of the main storage backend to use. Available backends are: +.TP +.EX +.B "postgres://<connection URI>" +.EE +Store website content in a Postgres database. Takes a connection URI. +.TP +.EX +.B "file://<path to a folder>" +.EE +Store website content in a folder on the local filesystem. +.P +.B NOTE: +This backend is not actively maintained and may not work as expected. +It does not implement some advanced features and will probably not receive +updates often. +.RE +.PP +.I $AUTHSTORE_URI +.RS 4 +The URI of the authentication backend to use. +This backend is responsible for storing access tokens and short-lived +authorization codes. +Available backends are: +.TP +.EX +.B "file://<path to a folder>" +.EE +Store authentication data in a folder on the filesystem. +.RE +.PP +.I $BLOBSTORE_URI +.RS 4 +The URI of the media store backend to use. +This backend manages file uploads and storage of post attachments. +.P +Available backends are: +.TP +.EX +.B "file://<path to a folder>" +.EE +Store file uploads in a content-addressed storage based on a folder. File +contents are hashed using SHA-256, and the hash is used to construct the path. +A small piece of metadata is stored next to the file in JSON format. +.RE +.PP +.I $JOB_QUEUE_URI +.RS 4 +The URI of the job queue backend to use. +This backend is responsible for some background tasks, like receiving and +validating Webmentions. +Available backends are: +.TP 4 +.EX +.B "postgres://<connection URI>" +.EE +Use PostgreSQL as a job queue. +This works better than one would expect. +.RE +.PP +.I $COOKIE_KEY +.RS 4 +A key for signing session cookies. +This needs to be kept secret. +.RE +.SH STANDARDS +Aaron Parecki, W3C, +.UR https://www.w3.org/TR/micropub/ +.I Micropub +.UE "," +23 May 2017. W3C Recommendation. +.P +Aaron Parecki, IndieWeb community, +.UR https://indieauth.spec.indieweb.org +.I IndieAuth +.UE "," +11 July 2024. Living Standard. +.SH SEE ALSO +.MR postgres 1 diff --git a/kittybox.nix b/kittybox.nix index b078c93..1b591f3 100644 --- a/kittybox.nix +++ b/kittybox.nix @@ -1,4 +1,4 @@ -{ crane, lib, nodePackages +{ crane, lib, installShellFiles, nodePackages , useWebAuthn ? false, openssl, zlib, pkg-config, protobuf , usePostgres ? true, postgresql, postgresqlTestHook , nixosTests }: @@ -9,7 +9,7 @@ assert usePostgres -> postgresql != null && postgresqlTestHook != null; let featureMatrix = features: lib.concatStringsSep " " (lib.attrNames (lib.filterAttrs (k: v: v) features)); - suffixes = [ ".sql" ".ts" ".css" ".html" ".json" ".woff2" ]; + suffixes = [ ".sql" ".ts" ".css" ".html" ".json" ".woff2" ".1" ]; suffixFilter = suffixes: name: type: let base = baseNameOf (toString name); in type == "directory" || lib.any (ext: lib.hasSuffix ext base) suffixes; @@ -34,7 +34,8 @@ let cargoExtraArgs = cargoFeatures; buildInputs = lib.optional useWebAuthn openssl; - nativeBuildInputs = [ nodePackages.typescript ] ++ (lib.optional useWebAuthn pkg-config); + nativeBuildInputs = [ nodePackages.typescript installShellFiles ] + ++ (lib.optional useWebAuthn pkg-config); meta = with lib.meta; { maintainers = with lib.maintainers; [ vikanezrimaya ]; @@ -50,13 +51,17 @@ in crane.buildPackage (args' // { nativeCheckInputs = lib.optionals usePostgres [ postgresql postgresqlTestHook ]; - + # Tests create arbitrary databases; we need to be prepared for that postgresqlTestUserOptions = "LOGIN SUPERUSER"; postgresqlTestSetupPost = '' export DATABASE_URL="postgres://localhost?host=$PGHOST&user=$PGUSER&dbname=$PGDATABASE" ''; + postInstall = '' + installManPage ./kittybox.1 + ''; + passthru = { tests = nixosTests; hasPostgres = usePostgres; diff --git a/src/bin/kittybox-check-webmention.rs b/src/bin/kittybox-check-webmention.rs index b43980e..a9e5957 100644 --- a/src/bin/kittybox-check-webmention.rs +++ b/src/bin/kittybox-check-webmention.rs @@ -7,7 +7,7 @@ enum Error { #[error("reqwest error: {0}")] Http(#[from] reqwest::Error), #[error("webmention check error: {0}")] - Webmention(#[from] WebmentionError) + Webmention(#[from] WebmentionError), } #[derive(Parser, Debug)] @@ -21,7 +21,7 @@ struct Args { #[clap(value_parser)] url: url::Url, #[clap(value_parser)] - link: url::Url + link: url::Url, } #[tokio::main] @@ -30,10 +30,11 @@ async fn main() -> Result<(), Error> { let http: reqwest::Client = { #[allow(unused_mut)] - let mut builder = reqwest::Client::builder() - .user_agent(concat!( - env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") - )); + let mut builder = reqwest::Client::builder().user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); builder.build().unwrap() }; diff --git a/src/bin/kittybox-indieauth-helper.rs b/src/bin/kittybox-indieauth-helper.rs index f4ad679..0725aac 100644 --- a/src/bin/kittybox-indieauth-helper.rs +++ b/src/bin/kittybox-indieauth-helper.rs @@ -1,13 +1,11 @@ +use clap::Parser; use futures::{FutureExt, TryFutureExt}; use kittybox_indieauth::{ - AuthorizationRequest, PKCEVerifier, - PKCEChallenge, PKCEMethod, GrantRequest, Scope, - AuthorizationResponse, GrantResponse, - Error as IndieauthError + AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantRequest, + GrantResponse, PKCEChallenge, PKCEMethod, PKCEVerifier, Scope, }; -use clap::Parser; -use tokio::net::TcpListener; use std::{borrow::Cow, future::IntoFuture, io::Write}; +use tokio::net::TcpListener; const DEFAULT_CLIENT_ID: &str = "https://kittybox.fireburn.ru/indieauth-helper.html"; const DEFAULT_REDIRECT_URI: &str = "http://localhost:60000/callback"; @@ -21,7 +19,7 @@ enum Error { #[error("url parsing error: {0}")] UrlParse(#[from] url::ParseError), #[error("indieauth flow error: {0}")] - IndieAuth(#[from] IndieauthError) + IndieAuth(#[from] IndieauthError), } #[derive(Parser, Debug)] @@ -46,20 +44,20 @@ struct Args { client_id: url::Url, /// Redirect URI to declare. Note: This will break the flow, use only for testing UI. #[clap(long, value_parser)] - redirect_uri: Option<url::Url> + redirect_uri: Option<url::Url>, } - #[tokio::main] async fn main() -> Result<(), Error> { let args = Args::parse(); let http: reqwest::Client = { #[allow(unused_mut)] - let mut builder = reqwest::Client::builder() - .user_agent(concat!( - env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") - )); + let mut builder = reqwest::Client::builder().user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); // This only works on debug builds. Don't get any funny thoughts. #[cfg(debug_assertions)] if std::env::var("KITTYBOX_DANGER_INSECURE_TLS") @@ -71,12 +69,14 @@ async fn main() -> Result<(), Error> { builder.build().unwrap() }; - let redirect_uri: url::Url = args.redirect_uri + let redirect_uri: url::Url = args + .redirect_uri .clone() .unwrap_or_else(|| DEFAULT_REDIRECT_URI.parse().unwrap()); eprintln!("Checking .well-known for metadata..."); - let metadata = http.get(args.me.join("/.well-known/oauth-authorization-server")?) + let metadata = http + .get(args.me.join("/.well-known/oauth-authorization-server")?) .header("Accept", "application/json") .send() .await? @@ -92,7 +92,7 @@ async fn main() -> Result<(), Error> { state: kittybox_indieauth::State::new(), code_challenge: PKCEChallenge::new(&verifier, PKCEMethod::default()), scope: Some(kittybox_indieauth::Scopes::new(args.scope)), - me: Some(args.me) + me: Some(args.me), }; let indieauth_url = { @@ -103,12 +103,18 @@ async fn main() -> Result<(), Error> { url }; - eprintln!("Please visit the following URL in your browser:\n\n {}\n", indieauth_url.as_str()); + eprintln!( + "Please visit the following URL in your browser:\n\n {}\n", + indieauth_url.as_str() + ); #[cfg(target_os = "linux")] - match std::process::Command::new("xdg-open").arg(indieauth_url.as_str()).spawn() { + match std::process::Command::new("xdg-open") + .arg(indieauth_url.as_str()) + .spawn() + { Ok(child) => drop(child), - Err(err) => eprintln!("Couldn't xdg-open: {}", err) + Err(err) => eprintln!("Couldn't xdg-open: {}", err), } if args.redirect_uri.is_some() { @@ -123,32 +129,38 @@ async fn main() -> Result<(), Error> { let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx))); - let router = axum::Router::new() - .route("/callback", axum::routing::get( + let router = axum::Router::new().route( + "/callback", + axum::routing::get( move |Query(response): Query<AuthorizationResponse>| async move { if let Some(tx) = tx.lock_owned().await.take() { tx.send(response).unwrap(); - (axum::http::StatusCode::OK, - [("Content-Type", "text/plain")], - "Thank you! This window can now be closed.") + ( + axum::http::StatusCode::OK, + [("Content-Type", "text/plain")], + "Thank you! This window can now be closed.", + ) .into_response() } else { - (axum::http::StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "Oops. The callback was already received. Did you click twice?") + ( + axum::http::StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "Oops. The callback was already received. Did you click twice?", + ) .into_response() } - } - )); + }, + ), + ); - use std::net::{SocketAddr, IpAddr, Ipv4Addr}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; let server = axum::serve( - TcpListener::bind( - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),60000) - ).await.unwrap(), - router.into_make_service() + TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 60000)) + .await + .unwrap(), + router.into_make_service(), ); tokio::task::spawn(server.into_future()) @@ -175,12 +187,13 @@ async fn main() -> Result<(), Error> { #[cfg(not(debug_assertions))] std::process::exit(1); } - let response: Result<GrantResponse, IndieauthError> = http.post(metadata.token_endpoint) + let response: Result<GrantResponse, IndieauthError> = http + .post(metadata.token_endpoint) .form(&GrantRequest::AuthorizationCode { code: authorization_response.code, client_id: args.client_id, redirect_uri, - code_verifier: verifier + code_verifier: verifier, }) .header("Accept", "application/json") .send() @@ -201,9 +214,14 @@ async fn main() -> Result<(), Error> { refresh_token, scope, .. - } = response? { - eprintln!("Congratulations, {}, access token is ready! {}", - profile.as_ref().and_then(|p| p.name.as_deref()).unwrap_or(me.as_str()), + } = response? + { + eprintln!( + "Congratulations, {}, access token is ready! {}", + profile + .as_ref() + .and_then(|p| p.name.as_deref()) + .unwrap_or(me.as_str()), if let Some(exp) = expires_in { Cow::Owned(format!("It expires in {exp} seconds.")) } else { diff --git a/src/bin/kittybox-mf2.rs b/src/bin/kittybox-mf2.rs index 0cd89b4..b6f4999 100644 --- a/src/bin/kittybox-mf2.rs +++ b/src/bin/kittybox-mf2.rs @@ -37,8 +37,9 @@ async fn main() -> Result<(), Error> { .with_indent_lines(true) .with_verbose_exit(true), #[cfg(not(debug_assertions))] - tracing_subscriber::fmt::layer().json() - .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout().lock())) + tracing_subscriber::fmt::layer() + .json() + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout().lock())), ); tracing_registry.init(); @@ -46,10 +47,11 @@ async fn main() -> Result<(), Error> { let http: reqwest::Client = { #[allow(unused_mut)] - let mut builder = reqwest::Client::builder() - .user_agent(concat!( - env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") - )); + let mut builder = reqwest::Client::builder().user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); builder.build().unwrap() }; diff --git a/src/companion.rs b/src/companion.rs new file mode 100644 index 0000000..debc266 --- /dev/null +++ b/src/companion.rs @@ -0,0 +1,84 @@ +use axum::{ + extract::{Extension, Path}, + response::{IntoResponse, Response}, +}; +use std::{collections::HashMap, sync::Arc}; + +#[derive(Debug, Clone, Copy)] +struct Resource { + data: &'static [u8], + mime: &'static str, +} + +impl IntoResponse for &Resource { + fn into_response(self) -> Response { + ( + axum::http::StatusCode::OK, + [("Content-Type", self.mime)], + self.data, + ) + .into_response() + } +} + +// TODO replace with the "phf" crate someday +type ResourceTable = Arc<HashMap<&'static str, Resource>>; + +#[tracing::instrument] +async fn map_to_static( + Path(name): Path<String>, + Extension(resources): Extension<ResourceTable>, +) -> Response { + tracing::debug!("Searching for {} in the resource table...", name); + match resources.get(name.as_str()) { + Some(res) => res.into_response(), + None => { + #[cfg(debug_assertions)] + tracing::error!("Not found"); + + ( + axum::http::StatusCode::NOT_FOUND, + [("Content-Type", "text/plain")], + "Not found. Sorry.".as_bytes(), + ) + .into_response() + } + } +} + +pub fn router<St: Clone + Send + Sync + 'static>() -> axum::Router<St> { + let resources: ResourceTable = { + let mut map = HashMap::new(); + + macro_rules! register_resource { + ($map:ident, $prefix:expr, ($filename:literal, $mime:literal)) => {{ + $map.insert($filename, Resource { + data: include_bytes!(concat!($prefix, $filename)), + mime: $mime + }) + }}; + ($map:ident, $prefix:expr, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{ + register_resource!($map, $prefix, ($filename, $mime)); + register_resource!($map, $prefix, $(($f, $m)),+); + }}; + } + + register_resource! { + map, + concat!(env!("OUT_DIR"), "/", "companion", "/"), + ("index.html", "text/html; charset=\"utf-8\""), + ("main.js", "text/javascript"), + ("micropub_api.js", "text/javascript"), + ("indieauth.js", "text/javascript"), + ("base64.js", "text/javascript"), + ("style.css", "text/css") + }; + + Arc::new(map) + }; + + axum::Router::new().route( + "/{filename}", + axum::routing::get(map_to_static).layer(Extension(resources)), + ) +} diff --git a/src/database/file/mod.rs b/src/database/file/mod.rs index db9bb22..5c93beb 100644 --- a/src/database/file/mod.rs +++ b/src/database/file/mod.rs @@ -1,6 +1,6 @@ //#![warn(clippy::unwrap_used)] -use crate::database::{ErrorKind, Result, settings, Storage, StorageError}; -use crate::micropub::{MicropubUpdate, MicropubPropertyDeletion}; +use crate::database::{settings, ErrorKind, Result, Storage, StorageError}; +use crate::micropub::{MicropubPropertyDeletion, MicropubUpdate}; use futures::{stream, StreamExt, TryStreamExt}; use kittybox_util::MentionType; use serde_json::json; @@ -247,7 +247,9 @@ async fn hydrate_author<S: Storage>( impl Storage for FileStorage { async fn new(url: &'_ url::Url) -> Result<Self> { // TODO: sanity check - Ok(Self { root_dir: PathBuf::from(url.path()) }) + Ok(Self { + root_dir: PathBuf::from(url.path()), + }) } #[tracing::instrument(skip(self))] async fn categories(&self, url: &str) -> Result<Vec<String>> { @@ -259,7 +261,7 @@ impl Storage for FileStorage { // perform well. Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - "?q=category queries are not implemented due to resource constraints" + "?q=category queries are not implemented due to resource constraints", ))? } @@ -340,7 +342,10 @@ impl Storage for FileStorage { file.sync_all().await?; drop(file); tokio::fs::rename(&tempfile, &path).await?; - tokio::fs::File::open(path.parent().unwrap()).await?.sync_all().await?; + tokio::fs::File::open(path.parent().unwrap()) + .await? + .sync_all() + .await?; if let Some(urls) = post["properties"]["url"].as_array() { for url in urls.iter().map(|i| i.as_str().unwrap()) { @@ -350,8 +355,8 @@ impl Storage for FileStorage { "{}{}", url.host_str().unwrap(), url.port() - .map(|port| format!(":{}", port)) - .unwrap_or_default() + .map(|port| format!(":{}", port)) + .unwrap_or_default() ) }; if url != key && url_domain == user.authority() { @@ -410,26 +415,24 @@ impl Storage for FileStorage { .create(false) .open(&path) .await - { - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Vec::default() - } - Err(err) => { - // Propagate the error upwards - return Err(err.into()); - } - Ok(mut file) => { - let mut content = String::new(); - file.read_to_string(&mut content).await?; - drop(file); - - if !content.is_empty() { - serde_json::from_str(&content)? - } else { - Vec::default() - } - } - } + { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::default(), + Err(err) => { + // Propagate the error upwards + return Err(err.into()); + } + Ok(mut file) => { + let mut content = String::new(); + file.read_to_string(&mut content).await?; + drop(file); + + if !content.is_empty() { + serde_json::from_str(&content)? + } else { + Vec::default() + } + } + } }; channels.push(super::MicropubChannel { @@ -444,7 +447,10 @@ impl Storage for FileStorage { tempfile.sync_all().await?; drop(tempfile); tokio::fs::rename(tempfilename, &path).await?; - tokio::fs::File::open(path.parent().unwrap()).await?.sync_all().await?; + tokio::fs::File::open(path.parent().unwrap()) + .await? + .sync_all() + .await?; } Ok(()) } @@ -476,7 +482,10 @@ impl Storage for FileStorage { temp.sync_all().await?; drop(temp); tokio::fs::rename(tempfilename, &path).await?; - tokio::fs::File::open(path.parent().unwrap()).await?.sync_all().await?; + tokio::fs::File::open(path.parent().unwrap()) + .await? + .sync_all() + .await?; (json, new_json) }; @@ -486,7 +495,9 @@ impl Storage for FileStorage { #[tracing::instrument(skip(self, f), fields(f = std::any::type_name::<F>()))] async fn update_with<F: FnOnce(&mut serde_json::Value) + Send>( - &self, url: &str, f: F + &self, + url: &str, + f: F, ) -> Result<(serde_json::Value, serde_json::Value)> { todo!("update_with is not yet implemented due to special requirements of the file backend") } @@ -526,25 +537,25 @@ impl Storage for FileStorage { url: &'_ str, cursor: Option<&'_ str>, limit: usize, - user: Option<&url::Url> + user: Option<&url::Url>, ) -> Result<Option<(serde_json::Value, Option<String>)>> { #[allow(deprecated)] - Ok(self.read_feed_with_limit( - url, - cursor, - limit, - user - ).await? + Ok(self + .read_feed_with_limit(url, cursor, limit, user) + .await? .map(|feed| { - tracing::debug!("Feed: {:#}", serde_json::Value::Array( - feed["children"] - .as_array() - .map(|v| v.as_slice()) - .unwrap_or_default() - .iter() - .map(|mf2| mf2["properties"]["uid"][0].clone()) - .collect::<Vec<_>>() - )); + tracing::debug!( + "Feed: {:#}", + serde_json::Value::Array( + feed["children"] + .as_array() + .map(|v| v.as_slice()) + .unwrap_or_default() + .iter() + .map(|mf2| mf2["properties"]["uid"][0].clone()) + .collect::<Vec<_>>() + ) + ); let cursor: Option<String> = feed["children"] .as_array() .map(|v| v.as_slice()) @@ -553,8 +564,7 @@ impl Storage for FileStorage { .map(|v| v["properties"]["uid"][0].as_str().unwrap().to_owned()); tracing::debug!("Extracted the cursor: {:?}", cursor); (feed, cursor) - }) - ) + })) } #[tracing::instrument(skip(self))] @@ -574,9 +584,12 @@ impl Storage for FileStorage { let children: Vec<serde_json::Value> = match feed["children"].take() { serde_json::Value::Array(children) => children, // We've already checked it's an array - _ => unreachable!() + _ => unreachable!(), }; - tracing::debug!("Full children array: {:#}", serde_json::Value::Array(children.clone())); + tracing::debug!( + "Full children array: {:#}", + serde_json::Value::Array(children.clone()) + ); let mut posts_iter = children .into_iter() .map(|s: serde_json::Value| s.as_str().unwrap().to_string()); @@ -589,7 +602,7 @@ impl Storage for FileStorage { // incredibly long feeds. if let Some(after) = after { tokio::task::block_in_place(|| { - for s in posts_iter.by_ref() { + for s in posts_iter.by_ref() { if s == after { break; } @@ -603,6 +616,7 @@ impl Storage for FileStorage { // Broken links return None, and Stream::filter_map skips Nones. .try_filter_map(|post: Option<serde_json::Value>| async move { Ok(post) }) .and_then(|mut post| async move { + // XXX: N+1 problem, potential sanitization issues hydrate_author(&mut post, user, self).await; Ok(post) }) @@ -654,12 +668,19 @@ impl Storage for FileStorage { let settings: HashMap<&str, serde_json::Value> = serde_json::from_str(&content)?; match settings.get(S::ID) { Some(value) => Ok(serde_json::from_value::<S>(value.clone())?), - None => Err(StorageError::from_static(ErrorKind::Backend, "Setting not set")) + None => Err(StorageError::from_static( + ErrorKind::Backend, + "Setting not set", + )), } } #[tracing::instrument(skip(self))] - async fn set_setting<S: settings::Setting>(&self, user: &url::Url, value: S::Data) -> Result<()> { + async fn set_setting<S: settings::Setting>( + &self, + user: &url::Url, + value: S::Data, + ) -> Result<()> { let mut path = relative_path::RelativePathBuf::new(); path.push(user.authority()); path.push("settings"); @@ -703,20 +724,28 @@ impl Storage for FileStorage { tempfile.sync_all().await?; drop(tempfile); tokio::fs::rename(temppath, &path).await?; - tokio::fs::File::open(path.parent().unwrap()).await?.sync_all().await?; + tokio::fs::File::open(path.parent().unwrap()) + .await? + .sync_all() + .await?; Ok(()) } #[tracing::instrument(skip(self))] - async fn add_or_update_webmention(&self, target: &str, mention_type: MentionType, mention: serde_json::Value) -> Result<()> { + async fn add_or_update_webmention( + &self, + target: &str, + mention_type: MentionType, + mention: serde_json::Value, + ) -> Result<()> { let path = url_to_path(&self.root_dir, target); let tempfilename = path.with_extension("tmp"); let mut temp = OpenOptions::new() - .write(true) - .create_new(true) - .open(&tempfilename) - .await?; + .write(true) + .create_new(true) + .open(&tempfilename) + .await?; let mut file = OpenOptions::new().read(true).open(&path).await?; let mut post: serde_json::Value = { @@ -751,13 +780,20 @@ impl Storage for FileStorage { temp.sync_all().await?; drop(temp); tokio::fs::rename(tempfilename, &path).await?; - tokio::fs::File::open(path.parent().unwrap()).await?.sync_all().await?; + tokio::fs::File::open(path.parent().unwrap()) + .await? + .sync_all() + .await?; Ok(()) } - async fn all_posts<'this>(&'this self, user: &url::Url) -> Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this> { + async fn all_posts<'this>( + &'this self, + user: &url::Url, + ) -> Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this> { todo!(); + #[allow(unreachable_code)] Ok(futures::stream::empty()) // for type inference } } diff --git a/src/database/memory.rs b/src/database/memory.rs index 412deef..75f04de 100644 --- a/src/database/memory.rs +++ b/src/database/memory.rs @@ -1,13 +1,14 @@ -#![allow(clippy::todo)] +#![allow(clippy::todo, missing_docs)] use futures_util::FutureExt; use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use crate::database::{ErrorKind, MicropubChannel, Result, settings, Storage, StorageError}; +use crate::database::{settings, ErrorKind, MicropubChannel, Result, Storage, StorageError}; #[derive(Clone, Debug, Default)] +/// A simple in-memory store for testing purposes. pub struct MemoryStorage { pub mapping: Arc<RwLock<HashMap<String, serde_json::Value>>>, pub channels: Arc<RwLock<HashMap<url::Url, Vec<String>>>>, @@ -89,9 +90,16 @@ impl Storage for MemoryStorage { Ok(()) } - async fn update_post(&self, url: &'_ str, update: crate::micropub::MicropubUpdate) -> Result<()> { + async fn update_post( + &self, + url: &'_ str, + update: crate::micropub::MicropubUpdate, + ) -> Result<()> { let mut guard = self.mapping.write().await; - let mut post = guard.get_mut(url).ok_or(StorageError::from_static(ErrorKind::NotFound, "The specified post wasn't found in the database."))?; + let mut post = guard.get_mut(url).ok_or(StorageError::from_static( + ErrorKind::NotFound, + "The specified post wasn't found in the database.", + ))?; use crate::micropub::MicropubPropertyDeletion; @@ -207,7 +215,7 @@ impl Storage for MemoryStorage { url: &'_ str, cursor: Option<&'_ str>, limit: usize, - user: Option<&url::Url> + user: Option<&url::Url>, ) -> Result<Option<(serde_json::Value, Option<String>)>> { todo!() } @@ -223,25 +231,39 @@ impl Storage for MemoryStorage { } #[allow(unused_variables)] - async fn set_setting<S: settings::Setting>(&self, user: &url::Url, value: S::Data) -> Result<()> { + async fn set_setting<S: settings::Setting>( + &self, + user: &url::Url, + value: S::Data, + ) -> Result<()> { todo!() } #[allow(unused_variables)] - async fn add_or_update_webmention(&self, target: &str, mention_type: kittybox_util::MentionType, mention: serde_json::Value) -> Result<()> { + async fn add_or_update_webmention( + &self, + target: &str, + mention_type: kittybox_util::MentionType, + mention: serde_json::Value, + ) -> Result<()> { todo!() } #[allow(unused_variables)] async fn update_with<F: FnOnce(&mut serde_json::Value) + Send>( - &self, url: &str, f: F + &self, + url: &str, + f: F, ) -> Result<(serde_json::Value, serde_json::Value)> { todo!() } - async fn all_posts<'this>(&'this self, user: &url::Url) -> Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this> { + async fn all_posts<'this>( + &'this self, + _user: &url::Url, + ) -> Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this> { todo!(); + #[allow(unreachable_code)] Ok(futures::stream::pending()) } - } diff --git a/src/database/mod.rs b/src/database/mod.rs index 4390ae7..fb6f43c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -43,71 +43,7 @@ pub enum ErrorKind { } /// Settings that can be stored in the database. -pub mod settings { - mod private { - pub trait Sealed {} - } - - /// A trait for various settings that should be contained here. - /// - /// **Note**: this trait is sealed to prevent external - /// implementations, as it wouldn't make sense to add new settings - /// that aren't used by Kittybox itself. - pub trait Setting: private::Sealed + std::fmt::Debug + Default + Clone + serde::Serialize + serde::de::DeserializeOwned + /*From<Settings> +*/ Send + Sync + 'static { - /// The data that the setting carries. - type Data: std::fmt::Debug + Send + Sync; - /// The string ID for the setting, usable as an identifier in the database. - const ID: &'static str; - - /// Unwrap the setting type, returning owned data contained within. - fn into_inner(self) -> Self::Data; - /// Create a new instance of this type containing certain data. - fn new(data: Self::Data) -> Self; - } - - /// A website's title, shown in the header. - #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)] - pub struct SiteName(pub(crate) String); - impl Default for SiteName { - fn default() -> Self { - Self("Kittybox".to_string()) - } - } - impl AsRef<str> for SiteName { - fn as_ref(&self) -> &str { - self.0.as_str() - } - } - impl private::Sealed for SiteName {} - impl Setting for SiteName { - type Data = String; - const ID: &'static str = "site_name"; - - fn into_inner(self) -> String { - self.0 - } - fn new(data: Self::Data) -> Self { - Self(data) - } - } - - /// Participation status in the IndieWeb Webring: https://🕸💍.ws/dashboard - #[derive(Debug, Default, serde::Deserialize, serde::Serialize, Clone, Copy, PartialEq, Eq)] - pub struct Webring(bool); - impl private::Sealed for Webring {} - impl Setting for Webring { - type Data = bool; - const ID: &'static str = "webring"; - - fn into_inner(self) -> Self::Data { - self.0 - } - - fn new(data: Self::Data) -> Self { - Self(data) - } - } -} +pub mod settings; /// Error signalled from the database. #[derive(Debug)] @@ -177,7 +113,7 @@ impl StorageError { Self { msg: Cow::Borrowed(msg), source: None, - kind + kind, } } /// Create a StorageError using another arbitrary Error as a source. @@ -219,27 +155,34 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { fn post_exists(&self, url: &str) -> impl Future<Output = Result<bool>> + Send; /// Load a post from the database in MF2-JSON format, deserialized from JSON. - fn get_post(&self, url: &str) -> impl Future<Output = Result<Option<serde_json::Value>>> + Send; + fn get_post(&self, url: &str) + -> impl Future<Output = Result<Option<serde_json::Value>>> + Send; /// Save a post to the database as an MF2-JSON structure. /// /// Note that the `post` object MUST have `post["properties"]["uid"][0]` defined. - fn put_post(&self, post: &serde_json::Value, user: &url::Url) -> impl Future<Output = Result<()>> + Send; + fn put_post( + &self, + post: &serde_json::Value, + user: &url::Url, + ) -> impl Future<Output = Result<()>> + Send; /// Add post to feed. Some database implementations might have optimized ways to do this. #[tracing::instrument(skip(self))] fn add_to_feed(&self, feed: &str, post: &str) -> impl Future<Output = Result<()>> + Send { tracing::debug!("Inserting {} into {} using `update_post`", post, feed); - self.update_post(feed, serde_json::from_value( - serde_json::json!({"add": {"children": [post]}})).unwrap() + self.update_post( + feed, + serde_json::from_value(serde_json::json!({"add": {"children": [post]}})).unwrap(), ) } /// Remove post from feed. Some database implementations might have optimized ways to do this. #[tracing::instrument(skip(self))] fn remove_from_feed(&self, feed: &str, post: &str) -> impl Future<Output = Result<()>> + Send { tracing::debug!("Removing {} into {} using `update_post`", post, feed); - self.update_post(feed, serde_json::from_value( - serde_json::json!({"delete": {"children": [post]}})).unwrap() + self.update_post( + feed, + serde_json::from_value(serde_json::json!({"delete": {"children": [post]}})).unwrap(), ) } @@ -254,7 +197,11 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { /// /// Default implementation calls [`Storage::update_with`] and uses /// [`update.apply`][MicropubUpdate::apply] to update the post. - fn update_post(&self, url: &str, update: MicropubUpdate) -> impl Future<Output = Result<()>> + Send { + fn update_post( + &self, + url: &str, + update: MicropubUpdate, + ) -> impl Future<Output = Result<()>> + Send { let fut = self.update_with(url, |post| { update.apply(post); }); @@ -274,12 +221,17 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { /// /// Returns old post and the new post after editing. fn update_with<F: FnOnce(&mut serde_json::Value) + Send>( - &self, url: &str, f: F + &self, + url: &str, + f: F, ) -> impl Future<Output = Result<(serde_json::Value, serde_json::Value)>> + Send; /// Get a list of channels available for the user represented by /// the `user` domain to write to. - fn get_channels(&self, user: &url::Url) -> impl Future<Output = Result<Vec<MicropubChannel>>> + Send; + fn get_channels( + &self, + user: &url::Url, + ) -> impl Future<Output = Result<Vec<MicropubChannel>>> + Send; /// Fetch a feed at `url` and return an h-feed object containing /// `limit` posts after a post by url `after`, filtering the content @@ -329,7 +281,7 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { url: &'_ str, cursor: Option<&'_ str>, limit: usize, - user: Option<&url::Url> + user: Option<&url::Url>, ) -> impl Future<Output = Result<Option<(serde_json::Value, Option<String>)>>> + Send; /// Deletes a post from the database irreversibly. Must be idempotent. @@ -339,7 +291,11 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { fn get_setting<S: Setting>(&self, user: &url::Url) -> impl Future<Output = Result<S>> + Send; /// Commits a setting to the setting store. - fn set_setting<S: Setting>(&self, user: &url::Url, value: S::Data) -> impl Future<Output = Result<()>> + Send; + fn set_setting<S: Setting>( + &self, + user: &url::Url, + value: S::Data, + ) -> impl Future<Output = Result<()>> + Send; /// Add (or update) a webmention on a certian post. /// @@ -355,11 +311,19 @@ pub trait Storage: std::fmt::Debug + Clone + Send + Sync { /// /// Besides, it may even allow for nice tricks like storing the /// webmentions separately and rehydrating them on feed reads. - fn add_or_update_webmention(&self, target: &str, mention_type: MentionType, mention: serde_json::Value) -> impl Future<Output = Result<()>> + Send; + fn add_or_update_webmention( + &self, + target: &str, + mention_type: MentionType, + mention: serde_json::Value, + ) -> impl Future<Output = Result<()>> + Send; /// Return a stream of all posts ever made by a certain user, in /// reverse-chronological order. - fn all_posts<'this>(&'this self, user: &url::Url) -> impl Future<Output = Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this>> + Send; + fn all_posts<'this>( + &'this self, + user: &url::Url, + ) -> impl Future<Output = Result<impl futures::Stream<Item = serde_json::Value> + Send + 'this>> + Send; } #[cfg(test)] @@ -464,7 +428,8 @@ mod tests { "replace": { "content": ["Different test content"] } - })).unwrap(), + })) + .unwrap(), ) .await .unwrap(); @@ -511,7 +476,10 @@ mod tests { .put_post(&feed, &"https://fireburn.ru/".parse().unwrap()) .await .unwrap(); - let chans = backend.get_channels(&"https://fireburn.ru/".parse().unwrap()).await.unwrap(); + let chans = backend + .get_channels(&"https://fireburn.ru/".parse().unwrap()) + .await + .unwrap(); assert_eq!(chans.len(), 1); assert_eq!( chans[0], @@ -526,16 +494,16 @@ mod tests { backend .set_setting::<settings::SiteName>( &"https://fireburn.ru/".parse().unwrap(), - "Vika's Hideout".to_owned() + "Vika's Hideout".to_owned(), ) .await .unwrap(); assert_eq!( backend - .get_setting::<settings::SiteName>(&"https://fireburn.ru/".parse().unwrap()) - .await - .unwrap() - .as_ref(), + .get_setting::<settings::SiteName>(&"https://fireburn.ru/".parse().unwrap()) + .await + .unwrap() + .as_ref(), "Vika's Hideout" ); } @@ -597,11 +565,9 @@ mod tests { async fn test_feed_pagination<Backend: Storage>(backend: Backend) { let posts = { - let mut posts = std::iter::from_fn( - || Some(gen_random_post("fireburn.ru")) - ) - .take(40) - .collect::<Vec<serde_json::Value>>(); + let mut posts = std::iter::from_fn(|| Some(gen_random_post("fireburn.ru"))) + .take(40) + .collect::<Vec<serde_json::Value>>(); // Reverse the array so it's in reverse-chronological order posts.reverse(); @@ -629,7 +595,10 @@ mod tests { .put_post(post, &"https://fireburn.ru/".parse().unwrap()) .await .unwrap(); - backend.add_to_feed(key, post["properties"]["uid"][0].as_str().unwrap()).await.unwrap(); + backend + .add_to_feed(key, post["properties"]["uid"][0].as_str().unwrap()) + .await + .unwrap(); } let limit: usize = 10; @@ -648,23 +617,16 @@ mod tests { .unwrap() .iter() .map(|post| post["properties"]["uid"][0].as_str().unwrap()) - .collect::<Vec<_>>() - [0..10], + .collect::<Vec<_>>()[0..10], posts .iter() .map(|post| post["properties"]["uid"][0].as_str().unwrap()) - .collect::<Vec<_>>() - [0..10] + .collect::<Vec<_>>()[0..10] ); tracing::debug!("Continuing with cursor: {:?}", cursor); let (result2, cursor2) = backend - .read_feed_with_cursor( - key, - cursor.as_deref(), - limit, - None, - ) + .read_feed_with_cursor(key, cursor.as_deref(), limit, None) .await .unwrap() .unwrap(); @@ -676,12 +638,7 @@ mod tests { tracing::debug!("Continuing with cursor: {:?}", cursor); let (result3, cursor3) = backend - .read_feed_with_cursor( - key, - cursor2.as_deref(), - limit, - None, - ) + .read_feed_with_cursor(key, cursor2.as_deref(), limit, None) .await .unwrap() .unwrap(); @@ -693,12 +650,7 @@ mod tests { tracing::debug!("Continuing with cursor: {:?}", cursor); let (result4, _) = backend - .read_feed_with_cursor( - key, - cursor3.as_deref(), - limit, - None, - ) + .read_feed_with_cursor(key, cursor3.as_deref(), limit, None) .await .unwrap() .unwrap(); @@ -725,24 +677,43 @@ mod tests { async fn test_webmention_addition<Backend: Storage>(db: Backend) { let post = gen_random_post("fireburn.ru"); - db.put_post(&post, &"https://fireburn.ru/".parse().unwrap()).await.unwrap(); + db.put_post(&post, &"https://fireburn.ru/".parse().unwrap()) + .await + .unwrap(); const TYPE: MentionType = MentionType::Reply; let target = post["properties"]["uid"][0].as_str().unwrap(); let mut reply = gen_random_mention("aaronparecki.com", TYPE, target); - let (read_post, _) = db.read_feed_with_cursor(target, None, 20, None).await.unwrap().unwrap(); + let (read_post, _) = db + .read_feed_with_cursor(target, None, 20, None) + .await + .unwrap() + .unwrap(); assert_eq!(post, read_post); - db.add_or_update_webmention(target, TYPE, reply.clone()).await.unwrap(); + db.add_or_update_webmention(target, TYPE, reply.clone()) + .await + .unwrap(); - let (read_post, _) = db.read_feed_with_cursor(target, None, 20, None).await.unwrap().unwrap(); + let (read_post, _) = db + .read_feed_with_cursor(target, None, 20, None) + .await + .unwrap() + .unwrap(); assert_eq!(read_post["properties"]["comment"][0], reply); - reply["properties"]["content"][0] = json!(rand::random::<faker_rand::lorem::Paragraphs>().to_string()); + reply["properties"]["content"][0] = + json!(rand::random::<faker_rand::lorem::Paragraphs>().to_string()); - db.add_or_update_webmention(target, TYPE, reply.clone()).await.unwrap(); - let (read_post, _) = db.read_feed_with_cursor(target, None, 20, None).await.unwrap().unwrap(); + db.add_or_update_webmention(target, TYPE, reply.clone()) + .await + .unwrap(); + let (read_post, _) = db + .read_feed_with_cursor(target, None, 20, None) + .await + .unwrap() + .unwrap(); assert_eq!(read_post["properties"]["comment"][0], reply); } @@ -752,16 +723,20 @@ mod tests { let post = { let mut post = gen_random_post("fireburn.ru"); let urls = post["properties"]["url"].as_array_mut().unwrap(); - urls.push(serde_json::Value::String( - PERMALINK.to_owned() - )); + urls.push(serde_json::Value::String(PERMALINK.to_owned())); post }; - db.put_post(&post, &"https://fireburn.ru/".parse().unwrap()).await.unwrap(); + db.put_post(&post, &"https://fireburn.ru/".parse().unwrap()) + .await + .unwrap(); for i in post["properties"]["url"].as_array().unwrap() { - let (read_post, _) = db.read_feed_with_cursor(i.as_str().unwrap(), None, 20, None).await.unwrap().unwrap(); + let (read_post, _) = db + .read_feed_with_cursor(i.as_str().unwrap(), None, 20, None) + .await + .unwrap() + .unwrap(); assert_eq!(read_post, post); } } @@ -786,7 +761,7 @@ mod tests { async fn $func_name() { let tempdir = tempfile::tempdir().expect("Failed to create tempdir"); let backend = super::super::FileStorage { - root_dir: tempdir.path().to_path_buf() + root_dir: tempdir.path().to_path_buf(), }; super::$func_name(backend).await } @@ -800,7 +775,7 @@ mod tests { #[tracing_test::traced_test] async fn $func_name( pool_opts: sqlx::postgres::PgPoolOptions, - connect_opts: sqlx::postgres::PgConnectOptions + connect_opts: sqlx::postgres::PgConnectOptions, ) -> Result<(), sqlx::Error> { let db = { //use sqlx::ConnectOptions; diff --git a/src/database/postgres/mod.rs b/src/database/postgres/mod.rs index af19fea..ec67efa 100644 --- a/src/database/postgres/mod.rs +++ b/src/database/postgres/mod.rs @@ -5,7 +5,7 @@ use kittybox_util::{micropub::Channel as MicropubChannel, MentionType}; use sqlx::{ConnectOptions, Executor, PgPool}; use super::settings::Setting; -use super::{Storage, Result, StorageError, ErrorKind}; +use super::{ErrorKind, Result, Storage, StorageError}; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); @@ -14,7 +14,7 @@ impl From<sqlx::Error> for StorageError { Self::with_source( super::ErrorKind::Backend, Cow::Owned(format!("sqlx error: {}", &value)), - Box::new(value) + Box::new(value), ) } } @@ -24,7 +24,7 @@ impl From<sqlx::migrate::MigrateError> for StorageError { Self::with_source( super::ErrorKind::Backend, Cow::Owned(format!("sqlx migration error: {}", &value)), - Box::new(value) + Box::new(value), ) } } @@ -32,14 +32,15 @@ impl From<sqlx::migrate::MigrateError> for StorageError { /// Micropub storage that uses a PostgreSQL database. #[derive(Debug, Clone)] pub struct PostgresStorage { - db: PgPool + db: PgPool, } impl PostgresStorage { /// Construct a [`PostgresStorage`] from a [`sqlx::PgPool`], /// running appropriate migrations. pub(crate) async fn from_pool(db: sqlx::PgPool) -> Result<Self> { - db.execute(sqlx::query("CREATE SCHEMA IF NOT EXISTS kittybox")).await?; + db.execute(sqlx::query("CREATE SCHEMA IF NOT EXISTS kittybox")) + .await?; MIGRATOR.run(&db).await?; Ok(Self { db }) } @@ -50,19 +51,22 @@ impl Storage for PostgresStorage { /// migrations on the database. async fn new(url: &'_ url::Url) -> Result<Self> { tracing::debug!("Postgres URL: {url}"); - let options = sqlx::postgres::PgConnectOptions::from_url(url)? - .options([("search_path", "kittybox")]); + let options = + sqlx::postgres::PgConnectOptions::from_url(url)?.options([("search_path", "kittybox")]); Self::from_pool( sqlx::postgres::PgPoolOptions::new() .max_connections(50) .connect_with(options) - .await? - ).await - + .await?, + ) + .await } - async fn all_posts<'this>(&'this self, user: &url::Url) -> Result<impl Stream<Item = serde_json::Value> + Send + 'this> { + async fn all_posts<'this>( + &'this self, + user: &url::Url, + ) -> Result<impl Stream<Item = serde_json::Value> + Send + 'this> { let authority = user.authority().to_owned(); Ok( sqlx::query_scalar::<_, serde_json::Value>("SELECT mf2 FROM kittybox.mf2_json WHERE owner = $1 ORDER BY (mf2_json.mf2 #>> '{properties,published,0}') DESC") @@ -74,18 +78,20 @@ impl Storage for PostgresStorage { #[tracing::instrument(skip(self))] async fn categories(&self, url: &str) -> Result<Vec<String>> { - sqlx::query_scalar::<_, String>(" + sqlx::query_scalar::<_, String>( + " SELECT jsonb_array_elements(mf2['properties']['category']) AS category FROM kittybox.mf2_json WHERE jsonb_typeof(mf2['properties']['category']) = 'array' AND uid LIKE ($1 + '%') GROUP BY category ORDER BY count(*) DESC -") - .bind(url) - .fetch_all(&self.db) - .await - .map_err(|err| err.into()) +", + ) + .bind(url) + .fetch_all(&self.db) + .await + .map_err(|err| err.into()) } #[tracing::instrument(skip(self))] async fn post_exists(&self, url: &str) -> Result<bool> { @@ -98,13 +104,14 @@ WHERE #[tracing::instrument(skip(self))] async fn get_post(&self, url: &str) -> Result<Option<serde_json::Value>> { - sqlx::query_as::<_, (serde_json::Value,)>("SELECT mf2 FROM kittybox.mf2_json WHERE uid = $1 OR mf2['properties']['url'] ? $1") - .bind(url) - .fetch_optional(&self.db) - .await - .map(|v| v.map(|v| v.0)) - .map_err(|err| err.into()) - + sqlx::query_as::<_, (serde_json::Value,)>( + "SELECT mf2 FROM kittybox.mf2_json WHERE uid = $1 OR mf2['properties']['url'] ? $1", + ) + .bind(url) + .fetch_optional(&self.db) + .await + .map(|v| v.map(|v| v.0)) + .map_err(|err| err.into()) } #[tracing::instrument(skip(self))] @@ -122,13 +129,15 @@ WHERE #[tracing::instrument(skip(self))] async fn add_to_feed(&self, feed: &'_ str, post: &'_ str) -> Result<()> { tracing::debug!("Inserting {} into {}", post, feed); - sqlx::query("INSERT INTO kittybox.children (parent, child) VALUES ($1, $2) ON CONFLICT DO NOTHING") - .bind(feed) - .bind(post) - .execute(&self.db) - .await - .map(|_| ()) - .map_err(Into::into) + sqlx::query( + "INSERT INTO kittybox.children (parent, child) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(feed) + .bind(post) + .execute(&self.db) + .await + .map(|_| ()) + .map_err(Into::into) } #[tracing::instrument(skip(self))] @@ -143,7 +152,12 @@ WHERE } #[tracing::instrument(skip(self))] - async fn add_or_update_webmention(&self, target: &str, mention_type: MentionType, mention: serde_json::Value) -> Result<()> { + async fn add_or_update_webmention( + &self, + target: &str, + mention_type: MentionType, + mention: serde_json::Value, + ) -> Result<()> { let mut txn = self.db.begin().await?; let (uid, mut post) = sqlx::query_as::<_, (String, serde_json::Value)>("SELECT uid, mf2 FROM kittybox.mf2_json WHERE uid = $1 OR mf2['properties']['url'] ? $1 FOR UPDATE") @@ -190,7 +204,9 @@ WHERE #[tracing::instrument(skip(self), fields(f = std::any::type_name::<F>()))] async fn update_with<F: FnOnce(&mut serde_json::Value) + Send>( - &self, url: &str, f: F + &self, + url: &str, + f: F, ) -> Result<(serde_json::Value, serde_json::Value)> { tracing::debug!("Updating post {}", url); let mut txn = self.db.begin().await?; @@ -250,12 +266,12 @@ WHERE url: &'_ str, cursor: Option<&'_ str>, limit: usize, - user: Option<&url::Url> + user: Option<&url::Url>, ) -> Result<Option<(serde_json::Value, Option<String>)>> { let mut txn = self.db.begin().await?; sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") - .execute(&mut *txn) - .await?; + .execute(&mut *txn) + .await?; tracing::debug!("Started txn: {:?}", txn); let mut feed = match sqlx::query_scalar::<_, serde_json::Value>(" SELECT kittybox.hydrate_author(mf2) FROM kittybox.mf2_json WHERE uid = $1 OR mf2['properties']['url'] ? $1 @@ -273,11 +289,17 @@ SELECT kittybox.hydrate_author(mf2) FROM kittybox.mf2_json WHERE uid = $1 OR mf2 // The second query is very long and will probably be extremely // expensive. It's best to skip it on types where it doesn't make sense // (Kittybox doesn't support rendering children on non-feeds) - if !feed["type"].as_array().unwrap().iter().any(|t| *t == serde_json::json!("h-feed")) { + if !feed["type"] + .as_array() + .unwrap() + .iter() + .any(|t| *t == serde_json::json!("h-feed")) + { return Ok(Some((feed, None))); } - feed["children"] = sqlx::query_scalar::<_, serde_json::Value>(" + feed["children"] = sqlx::query_scalar::<_, serde_json::Value>( + " SELECT kittybox.hydrate_author(mf2) FROM kittybox.mf2_json INNER JOIN kittybox.children ON mf2_json.uid = children.child @@ -302,17 +324,19 @@ WHERE ) AND ($4 IS NULL OR ((mf2_json.mf2 #>> '{properties,published,0}') < $4)) ORDER BY (mf2_json.mf2 #>> '{properties,published,0}') DESC -LIMIT $2" +LIMIT $2", ) - .bind(url) - .bind(limit as i64) - .bind(user.map(url::Url::as_str)) - .bind(cursor) - .fetch_all(&mut *txn) - .await - .map(serde_json::Value::Array)?; - - let new_cursor = feed["children"].as_array().unwrap() + .bind(url) + .bind(limit as i64) + .bind(user.map(url::Url::as_str)) + .bind(cursor) + .fetch_all(&mut *txn) + .await + .map(serde_json::Value::Array)?; + + let new_cursor = feed["children"] + .as_array() + .unwrap() .last() .map(|v| v["properties"]["published"][0].as_str().unwrap().to_owned()); @@ -335,7 +359,7 @@ LIMIT $2" .await { Ok((value,)) => Ok(serde_json::from_value(value)?), - Err(err) => Err(err.into()) + Err(err) => Err(err.into()), } } diff --git a/src/database/settings.rs b/src/database/settings.rs new file mode 100644 index 0000000..792a155 --- /dev/null +++ b/src/database/settings.rs @@ -0,0 +1,63 @@ +mod private { + pub trait Sealed {} +} + +/// A trait for various settings that should be contained here. +/// +/// **Note**: this trait is sealed to prevent external +/// implementations, as it wouldn't make sense to add new settings +/// that aren't used by Kittybox itself. +pub trait Setting: private::Sealed + std::fmt::Debug + Default + Clone + serde::Serialize + serde::de::DeserializeOwned + /*From<Settings> +*/ Send + Sync + 'static { + /// The data that the setting carries. + type Data: std::fmt::Debug + Send + Sync; + /// The string ID for the setting, usable as an identifier in the database. + const ID: &'static str; + + /// Unwrap the setting type, returning owned data contained within. + fn into_inner(self) -> Self::Data; + /// Create a new instance of this type containing certain data. + fn new(data: Self::Data) -> Self; +} + +/// A website's title, shown in the header. +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)] +pub struct SiteName(pub(crate) String); +impl Default for SiteName { + fn default() -> Self { + Self("Kittybox".to_string()) + } +} +impl AsRef<str> for SiteName { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} +impl private::Sealed for SiteName {} +impl Setting for SiteName { + type Data = String; + const ID: &'static str = "site_name"; + + fn into_inner(self) -> String { + self.0 + } + fn new(data: Self::Data) -> Self { + Self(data) + } +} + +/// Participation status in the IndieWeb Webring: https://🕸💍.ws/dashboard +#[derive(Debug, Default, serde::Deserialize, serde::Serialize, Clone, Copy, PartialEq, Eq)] +pub struct Webring(bool); +impl private::Sealed for Webring {} +impl Setting for Webring { + type Data = bool; + const ID: &'static str = "webring"; + + fn into_inner(self) -> Self::Data { + self.0 + } + + fn new(data: Self::Data) -> Self { + Self(data) + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 9ba1a69..94b8aa7 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -12,12 +12,10 @@ use tracing::{debug, error}; //pub mod login; pub mod onboarding; +pub use kittybox_frontend_renderer::assets::statics; use kittybox_frontend_renderer::{ - Entry, Feed, VCard, - ErrorPage, Template, MainPage, - POSTS_PER_PAGE + Entry, ErrorPage, Feed, MainPage, Template, VCard, POSTS_PER_PAGE, }; -pub use kittybox_frontend_renderer::assets::statics; #[derive(Debug, Deserialize)] pub struct QueryParams { @@ -106,7 +104,7 @@ pub fn filter_post( .map(|i| -> &str { match i { serde_json::Value::String(ref author) => author.as_str(), - mf2 => mf2["properties"]["uid"][0].as_str().unwrap() + mf2 => mf2["properties"]["uid"][0].as_str().unwrap(), } }) .map(|i| i.parse().unwrap()) @@ -116,11 +114,13 @@ pub fn filter_post( .unwrap_or("public"); let audience = { let mut audience = author_list.clone(); - audience.extend(post["properties"]["audience"] - .as_array() - .unwrap_or(&empty_vec) - .iter() - .map(|i| i.as_str().unwrap().parse().unwrap())); + audience.extend( + post["properties"]["audience"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().parse().unwrap()), + ); audience }; @@ -134,7 +134,10 @@ pub fn filter_post( let location_visibility = post["properties"]["location-visibility"][0] .as_str() .unwrap_or("private"); - tracing::debug!("Post contains location, location privacy = {}", location_visibility); + tracing::debug!( + "Post contains location, location privacy = {}", + location_visibility + ); let mut author = post["properties"]["author"] .as_array() .unwrap_or(&empty_vec) @@ -155,16 +158,18 @@ pub fn filter_post( post["properties"]["author"] = serde_json::Value::Array( children .into_iter() - .filter_map(|post| if post.is_string() { - Some(post) - } else { - filter_post(post, user) + .filter_map(|post| { + if post.is_string() { + Some(post) + } else { + filter_post(post, user) + } }) - .collect::<Vec<serde_json::Value>>() + .collect::<Vec<serde_json::Value>>(), ); - }, - serde_json::Value::Null => {}, - other => post["properties"]["author"] = other + } + serde_json::Value::Null => {} + other => post["properties"]["author"] = other, } match post["children"].take() { @@ -173,11 +178,11 @@ pub fn filter_post( children .into_iter() .filter_map(|post| filter_post(post, user)) - .collect::<Vec<serde_json::Value>>() + .collect::<Vec<serde_json::Value>>(), ); - }, - serde_json::Value::Null => {}, - other => post["children"] = other + } + serde_json::Value::Null => {} + other => post["children"] = other, } Some(post) } @@ -209,7 +214,7 @@ async fn get_post_from_database<S: Storage>( )) } } - } + }, None => Err(FrontendError::with_code( StatusCode::NOT_FOUND, "Post not found in the database", @@ -240,7 +245,7 @@ pub async fn homepage<D: Storage>( Host(host): Host, Query(query): Query<QueryParams>, State(db): State<D>, - session: Option<crate::Session> + session: Option<crate::Session>, ) -> impl IntoResponse { // This is stupid, but there is no other way. let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); @@ -252,7 +257,7 @@ pub async fn homepage<D: Storage>( ); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, - axum::http::HeaderValue::from_static("nosniff") + axum::http::HeaderValue::from_static("nosniff"), ); let user = session.as_deref().map(|s| &s.me); @@ -268,18 +273,16 @@ pub async fn homepage<D: Storage>( // btw is it more efficient to fetch these in parallel? let (blogname, webring, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&hcard_url) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_setting::<crate::database::settings::Webring>(&hcard_url) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); if user.is_some() { headers.insert( axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("private") + axum::http::HeaderValue::from_static("private"), ); } // Render the homepage @@ -295,12 +298,13 @@ pub async fn homepage<D: Storage>( feed: &hfeed, card: &hcard, cursor: cursor.as_deref(), - webring: crate::database::settings::Setting::into_inner(webring) + webring: crate::database::settings::Setting::into_inner(webring), } .to_string(), } .to_string(), - ).into_response() + ) + .into_response() } Err(err) => { if err.code == StatusCode::NOT_FOUND { @@ -310,19 +314,20 @@ pub async fn homepage<D: Storage>( StatusCode::FOUND, [(axum::http::header::LOCATION, "/.kittybox/onboarding")], String::default(), - ).into_response() + ) + .into_response() } else { error!("Error while fetching h-card and/or h-feed: {}", err); // Return the error let (blogname, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&hcard_url) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); ( - err.code(), headers, + err.code(), + headers, Template { title: blogname.as_ref(), blog_name: blogname.as_ref(), @@ -335,7 +340,8 @@ pub async fn homepage<D: Storage>( .to_string(), } .to_string(), - ).into_response() + ) + .into_response() } } } @@ -351,17 +357,13 @@ pub async fn catchall<D: Storage>( ) -> impl IntoResponse { let user: Option<&url::Url> = session.as_deref().map(|p| &p.me); let host = url::Url::parse(&format!("https://{}/", host)).unwrap(); - let path = host - .clone() - .join(uri.path()) - .unwrap(); + let path = host.clone().join(uri.path()).unwrap(); match get_post_from_database(&db, path.as_str(), query.after, user).await { Ok((post, cursor)) => { let (blogname, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&host) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); let mut headers = axum::http::HeaderMap::new(); @@ -371,12 +373,12 @@ pub async fn catchall<D: Storage>( ); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, - axum::http::HeaderValue::from_static("nosniff") + axum::http::HeaderValue::from_static("nosniff"), ); if user.is_some() { headers.insert( axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("private") + axum::http::HeaderValue::from_static("private"), ); } @@ -384,19 +386,20 @@ pub async fn catchall<D: Storage>( let last_modified = post["properties"]["updated"] .as_array() .and_then(|v| v.last()) - .or_else(|| post["properties"]["published"] - .as_array() - .and_then(|v| v.last()) - ) + .or_else(|| { + post["properties"]["published"] + .as_array() + .and_then(|v| v.last()) + }) .and_then(serde_json::Value::as_str) - .and_then(|dt| chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(dt).ok()); + .and_then(|dt| { + chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(dt).ok() + }); if let Some(last_modified) = last_modified { - headers.typed_insert( - axum_extra::headers::LastModified::from( - std::time::SystemTime::from(last_modified) - ) - ); + headers.typed_insert(axum_extra::headers::LastModified::from( + std::time::SystemTime::from(last_modified), + )); } } @@ -410,8 +413,16 @@ pub async fn catchall<D: Storage>( feeds: channels, user: session.as_deref(), content: match post.pointer("/type/0").and_then(|i| i.as_str()) { - Some("h-entry") => Entry { post: &post, from_feed: false, }.to_string(), - Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(), + Some("h-entry") => Entry { + post: &post, + from_feed: false, + } + .to_string(), + Some("h-feed") => Feed { + feed: &post, + cursor: cursor.as_deref(), + } + .to_string(), Some("h-card") => VCard { card: &post }.to_string(), unknown => { unimplemented!("Template for MF2-JSON type {:?}", unknown) @@ -419,13 +430,13 @@ pub async fn catchall<D: Storage>( }, } .to_string(), - ).into_response() + ) + .into_response() } Err(err) => { let (blogname, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&host) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); ( @@ -446,7 +457,8 @@ pub async fn catchall<D: Storage>( .to_string(), } .to_string(), - ).into_response() + ) + .into_response() } } } diff --git a/src/frontend/onboarding.rs b/src/frontend/onboarding.rs index 4588157..3b53911 100644 --- a/src/frontend/onboarding.rs +++ b/src/frontend/onboarding.rs @@ -10,7 +10,7 @@ use axum::{ use axum_extra::extract::Host; use kittybox_frontend_renderer::{ErrorPage, OnboardingPage, Template}; use serde::Deserialize; -use tokio::{task::JoinSet, sync::Mutex}; +use tokio::{sync::Mutex, task::JoinSet}; use tracing::{debug, error}; use super::FrontendError; @@ -64,7 +64,8 @@ async fn onboard<D: Storage + 'static>( me: user_uid.clone(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]), - iat: None, exp: None + iat: None, + exp: None, }; tracing::debug!("User data: {:?}", user); @@ -84,7 +85,7 @@ async fn onboard<D: Storage + 'static>( .await .map_err(FrontendError::from)?; - let (_, hcard) = { + let crate::micropub::util::NormalizedPost { id: _, post: hcard } = { let mut hcard = data.user; hcard["properties"]["uid"] = serde_json::json!([&user_uid]); crate::micropub::normalize_mf2(hcard, &user) @@ -99,19 +100,21 @@ async fn onboard<D: Storage + 'static>( continue; }; debug!("Creating feed {} with slug {}", &feed.name, &feed.slug); - let (_, feed) = crate::micropub::normalize_mf2( - serde_json::json!({ - "type": ["h-feed"], - "properties": {"name": [feed.name], "mp-slug": [feed.slug]} - }), - &user, - ); + let crate::micropub::util::NormalizedPost { id: _, post: feed } = + crate::micropub::normalize_mf2( + serde_json::json!({ + "type": ["h-feed"], + "properties": {"name": [feed.name], "mp-slug": [feed.slug]} + }), + &user, + ); db.put_post(&feed, &user.me) .await .map_err(FrontendError::from)?; } - let (uid, post) = crate::micropub::normalize_mf2(data.first_post, &user); + let crate::micropub::util::NormalizedPost { id: uid, post } = + crate::micropub::normalize_mf2(data.first_post, &user); tracing::debug!("Posting first post {}...", uid); crate::micropub::_post(&user, uid, post, db, http, jobset) .await @@ -169,6 +172,5 @@ where reqwest_middleware::ClientWithMiddleware: FromRef<St>, St: Clone + Send + Sync + 'static, { - axum::routing::get(get) - .post(post::<S>) + axum::routing::get(get).post(post::<S>) } diff --git a/src/indieauth/backend.rs b/src/indieauth/backend.rs index b913256..9215adf 100644 --- a/src/indieauth/backend.rs +++ b/src/indieauth/backend.rs @@ -1,9 +1,7 @@ -use std::future::Future; -use std::collections::HashMap; -use kittybox_indieauth::{ - AuthorizationRequest, TokenData -}; +use kittybox_indieauth::{AuthorizationRequest, TokenData}; pub use kittybox_util::auth::EnrolledCredential; +use std::collections::HashMap; +use std::future::Future; type Result<T> = std::io::Result<T>; @@ -20,33 +18,72 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { /// Note for implementors: the [`AuthorizationRequest::me`] value /// is guaranteed to be [`Some(url::Url)`][Option::Some] and can /// be trusted to be correct and non-malicious. - fn create_code(&self, data: AuthorizationRequest) -> impl Future<Output = Result<String>> + Send; + fn create_code( + &self, + data: AuthorizationRequest, + ) -> impl Future<Output = Result<String>> + Send; /// Retreive an authorization request using the one-time /// code. Implementations must sanitize the `code` field to /// prevent exploits, and must check if the code should still be /// valid at this point in time (validity interval is left up to /// the implementation, but is recommended to be no more than 10 /// minutes). - fn get_code(&self, code: &str) -> impl Future<Output = Result<Option<AuthorizationRequest>>> + Send; + fn get_code( + &self, + code: &str, + ) -> impl Future<Output = Result<Option<AuthorizationRequest>>> + Send; // Token management. fn create_token(&self, data: TokenData) -> impl Future<Output = Result<String>> + Send; - fn get_token(&self, website: &url::Url, token: &str) -> impl Future<Output = Result<Option<TokenData>>> + Send; - fn list_tokens(&self, website: &url::Url) -> impl Future<Output = Result<HashMap<String, TokenData>>> + Send; - fn revoke_token(&self, website: &url::Url, token: &str) -> impl Future<Output = Result<()>> + Send; + fn get_token( + &self, + website: &url::Url, + token: &str, + ) -> impl Future<Output = Result<Option<TokenData>>> + Send; + fn list_tokens( + &self, + website: &url::Url, + ) -> impl Future<Output = Result<HashMap<String, TokenData>>> + Send; + fn revoke_token( + &self, + website: &url::Url, + token: &str, + ) -> impl Future<Output = Result<()>> + Send; // Refresh token management. fn create_refresh_token(&self, data: TokenData) -> impl Future<Output = Result<String>> + Send; - fn get_refresh_token(&self, website: &url::Url, token: &str) -> impl Future<Output = Result<Option<TokenData>>> + Send; - fn list_refresh_tokens(&self, website: &url::Url) -> impl Future<Output = Result<HashMap<String, TokenData>>> + Send; - fn revoke_refresh_token(&self, website: &url::Url, token: &str) -> impl Future<Output = Result<()>> + Send; + fn get_refresh_token( + &self, + website: &url::Url, + token: &str, + ) -> impl Future<Output = Result<Option<TokenData>>> + Send; + fn list_refresh_tokens( + &self, + website: &url::Url, + ) -> impl Future<Output = Result<HashMap<String, TokenData>>> + Send; + fn revoke_refresh_token( + &self, + website: &url::Url, + token: &str, + ) -> impl Future<Output = Result<()>> + Send; // Password management. /// Verify a password. #[must_use] - fn verify_password(&self, website: &url::Url, password: String) -> impl Future<Output = Result<bool>> + Send; + fn verify_password( + &self, + website: &url::Url, + password: String, + ) -> impl Future<Output = Result<bool>> + Send; /// Enroll a password credential for a user. Only one password /// credential must exist for a given user. - fn enroll_password(&self, website: &url::Url, password: String) -> impl Future<Output = Result<()>> + Send; + fn enroll_password( + &self, + website: &url::Url, + password: String, + ) -> impl Future<Output = Result<()>> + Send; /// List currently enrolled credential types for a given user. - fn list_user_credential_types(&self, website: &url::Url) -> impl Future<Output = Result<Vec<EnrolledCredential>>> + Send; + fn list_user_credential_types( + &self, + website: &url::Url, + ) -> impl Future<Output = Result<Vec<EnrolledCredential>>> + Send; // WebAuthn credential management. #[cfg(feature = "webauthn")] /// Enroll a WebAuthn authenticator public key for this user. @@ -56,10 +93,17 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { /// This function can also be used to overwrite a passkey with an /// updated version after using /// [webauthn::prelude::Passkey::update_credential()]. - fn enroll_webauthn(&self, website: &url::Url, credential: webauthn::prelude::Passkey) -> impl Future<Output = Result<()>> + Send; + fn enroll_webauthn( + &self, + website: &url::Url, + credential: webauthn::prelude::Passkey, + ) -> impl Future<Output = Result<()>> + Send; #[cfg(feature = "webauthn")] /// List currently enrolled WebAuthn authenticators for a given user. - fn list_webauthn_pubkeys(&self, website: &url::Url) -> impl Future<Output = Result<Vec<webauthn::prelude::Passkey>>> + Send; + fn list_webauthn_pubkeys( + &self, + website: &url::Url, + ) -> impl Future<Output = Result<Vec<webauthn::prelude::Passkey>>> + Send; #[cfg(feature = "webauthn")] /// Persist registration challenge state for a little while so it /// can be used later. @@ -69,7 +113,7 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { fn persist_registration_challenge( &self, website: &url::Url, - state: webauthn::prelude::PasskeyRegistration + state: webauthn::prelude::PasskeyRegistration, ) -> impl Future<Output = Result<String>> + Send; #[cfg(feature = "webauthn")] /// Retrieve a persisted registration challenge. @@ -78,7 +122,7 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { fn retrieve_registration_challenge( &self, website: &url::Url, - challenge_id: &str + challenge_id: &str, ) -> impl Future<Output = Result<webauthn::prelude::PasskeyRegistration>> + Send; #[cfg(feature = "webauthn")] /// Persist authentication challenge state for a little while so @@ -92,7 +136,7 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { fn persist_authentication_challenge( &self, website: &url::Url, - state: webauthn::prelude::PasskeyAuthentication + state: webauthn::prelude::PasskeyAuthentication, ) -> impl Future<Output = Result<String>> + Send; #[cfg(feature = "webauthn")] /// Retrieve a persisted authentication challenge. @@ -101,7 +145,6 @@ pub trait AuthBackend: Clone + Send + Sync + 'static { fn retrieve_authentication_challenge( &self, website: &url::Url, - challenge_id: &str + challenge_id: &str, ) -> impl Future<Output = Result<webauthn::prelude::PasskeyAuthentication>> + Send; - } diff --git a/src/indieauth/backend/fs.rs b/src/indieauth/backend/fs.rs index f74fbbc..26466fe 100644 --- a/src/indieauth/backend/fs.rs +++ b/src/indieauth/backend/fs.rs @@ -1,13 +1,16 @@ -use std::{path::PathBuf, collections::HashMap, borrow::Cow, time::{SystemTime, Duration}}; - -use super::{AuthBackend, Result, EnrolledCredential}; -use kittybox_indieauth::{ - AuthorizationRequest, TokenData +use std::{ + borrow::Cow, + collections::HashMap, + path::PathBuf, + time::{Duration, SystemTime}, }; + +use super::{AuthBackend, EnrolledCredential, Result}; +use kittybox_indieauth::{AuthorizationRequest, TokenData}; use serde::de::DeserializeOwned; -use tokio::{task::spawn_blocking, io::AsyncReadExt}; +use tokio::{io::AsyncReadExt, task::spawn_blocking}; #[cfg(feature = "webauthn")] -use webauthn::prelude::{Passkey, PasskeyRegistration, PasskeyAuthentication}; +use webauthn::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration}; const CODE_LENGTH: usize = 16; const TOKEN_LENGTH: usize = 128; @@ -29,7 +32,8 @@ impl FileBackend { } else { let mut s = String::with_capacity(filename.len()); - filename.chars() + filename + .chars() .filter(|c| c.is_alphanumeric()) .for_each(|c| s.push(c)); @@ -38,41 +42,41 @@ impl FileBackend { } #[inline] - async fn serialize_to_file<T: 'static + serde::ser::Serialize + Send, B: Into<Option<&'static str>>>( + async fn serialize_to_file< + T: 'static + serde::ser::Serialize + Send, + B: Into<Option<&'static str>>, + >( &self, dir: &str, basename: B, length: usize, - data: T + data: T, ) -> Result<String> { let basename = basename.into(); let has_ext = basename.is_some(); - let (filename, mut file) = kittybox_util::fs::mktemp( - self.path.join(dir), basename, length - ) + let (filename, mut file) = kittybox_util::fs::mktemp(self.path.join(dir), basename, length) .await .map(|(name, file)| (name, file.try_into_std().unwrap()))?; spawn_blocking(move || serde_json::to_writer(&mut file, &data)) .await - .unwrap_or_else(|e| panic!( - "Panic while serializing {}: {}", - std::any::type_name::<T>(), - e - )) + .unwrap_or_else(|e| { + panic!( + "Panic while serializing {}: {}", + std::any::type_name::<T>(), + e + ) + }) .map(move |_| { (if has_ext { - filename - .extension() - + filename.extension() } else { - filename - .file_name() + filename.file_name() }) - .unwrap() - .to_str() - .unwrap() - .to_owned() + .unwrap() + .to_str() + .unwrap() + .to_owned() }) .map_err(|err| err.into()) } @@ -86,17 +90,15 @@ impl FileBackend { ) -> Result<Option<(PathBuf, SystemTime, T)>> where T: serde::de::DeserializeOwned + Send, - B: Into<Option<&'static str>> + B: Into<Option<&'static str>>, { let basename = basename.into(); - let path = self.path - .join(dir) - .join(format!( - "{}{}{}", - basename.unwrap_or(""), - if basename.is_none() { "" } else { "." }, - FileBackend::sanitize_for_path(filename) - )); + let path = self.path.join(dir).join(format!( + "{}{}{}", + basename.unwrap_or(""), + if basename.is_none() { "" } else { "." }, + FileBackend::sanitize_for_path(filename) + )); let data = match tokio::fs::File::open(&path).await { Ok(mut file) => { @@ -106,13 +108,15 @@ impl FileBackend { match serde_json::from_slice::<'_, T>(buf.as_slice()) { Ok(data) => data, - Err(err) => return Err(err.into()) + Err(err) => return Err(err.into()), + } + } + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } else { + return Err(err); } - }, - Err(err) => if err.kind() == std::io::ErrorKind::NotFound { - return Ok(None) - } else { - return Err(err) } }; @@ -125,7 +129,8 @@ impl FileBackend { #[tracing::instrument] fn url_to_dir(url: &url::Url) -> String { let host = url.host_str().unwrap(); - let port = url.port() + let port = url + .port() .map(|port| Cow::Owned(format!(":{}", port))) .unwrap_or(Cow::Borrowed("")); @@ -135,23 +140,26 @@ impl FileBackend { async fn list_files<'dir, 'this: 'dir, T: DeserializeOwned + Send>( &'this self, dir: &'dir str, - prefix: &'static str + prefix: &'static str, ) -> Result<HashMap<String, T>> { let dir = self.path.join(dir); let mut hashmap = HashMap::new(); let mut readdir = match tokio::fs::read_dir(dir).await { Ok(readdir) => readdir, - Err(err) => if err.kind() == std::io::ErrorKind::NotFound { - // empty hashmap - return Ok(hashmap); - } else { - return Err(err); + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + // empty hashmap + return Ok(hashmap); + } else { + return Err(err); + } } }; while let Some(entry) = readdir.next_entry().await? { // safe to unwrap; filenames are alphanumeric - let filename = entry.file_name() + let filename = entry + .file_name() .into_string() .expect("token filenames should be alphanumeric!"); if let Some(token) = filename.strip_prefix(&format!("{}.", prefix)) { @@ -166,16 +174,19 @@ impl FileBackend { Err(err) => { tracing::error!( "Error decoding token data from file {}: {}", - entry.path().display(), err + entry.path().display(), + err ); continue; } }; - }, - Err(err) => if err.kind() == std::io::ErrorKind::NotFound { - continue - } else { - return Err(err) + } + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + continue; + } else { + return Err(err); + } } } } @@ -194,19 +205,27 @@ impl AuthBackend for FileBackend { path = base.join(&format!(".{}", path.path())).unwrap(); } - tracing::debug!("Initializing File auth backend: {} -> {}", orig_path, path.path()); + tracing::debug!( + "Initializing File auth backend: {} -> {}", + orig_path, + path.path() + ); Ok(Self { - path: std::path::PathBuf::from(path.path()) + path: std::path::PathBuf::from(path.path()), }) } // Authorization code management. async fn create_code(&self, data: AuthorizationRequest) -> Result<String> { - self.serialize_to_file("codes", None, CODE_LENGTH, data).await + self.serialize_to_file("codes", None, CODE_LENGTH, data) + .await } async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>> { - match self.deserialize_from_file("codes", None, FileBackend::sanitize_for_path(code).as_ref()).await? { + match self + .deserialize_from_file("codes", None, FileBackend::sanitize_for_path(code).as_ref()) + .await? + { Some((path, ctime, data)) => { if let Err(err) = tokio::fs::remove_file(path).await { tracing::error!("Failed to clean up authorization code: {}", err); @@ -217,23 +236,28 @@ impl AuthBackend for FileBackend { } else { Ok(Some(data)) } - }, - None => Ok(None) + } + None => Ok(None), } } // Token management. async fn create_token(&self, data: TokenData) -> Result<String> { let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me)); - self.serialize_to_file(&dir, "access", TOKEN_LENGTH, data).await + self.serialize_to_file(&dir, "access", TOKEN_LENGTH, data) + .await } async fn get_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> { let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); - match self.deserialize_from_file::<TokenData, _>( - &dir, "access", - FileBackend::sanitize_for_path(token).as_ref() - ).await? { + match self + .deserialize_from_file::<TokenData, _>( + &dir, + "access", + FileBackend::sanitize_for_path(token).as_ref(), + ) + .await? + { Some((path, _, token)) => { if token.expired() { if let Err(err) = tokio::fs::remove_file(path).await { @@ -243,8 +267,8 @@ impl AuthBackend for FileBackend { } else { Ok(Some(token)) } - }, - None => Ok(None) + } + None => Ok(None), } } @@ -258,25 +282,36 @@ impl AuthBackend for FileBackend { self.path .join(FileBackend::url_to_dir(website)) .join("tokens") - .join(format!("access.{}", FileBackend::sanitize_for_path(token))) - ).await { + .join(format!("access.{}", FileBackend::sanitize_for_path(token))), + ) + .await + { Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - result => result + result => result, } } // Refresh token management. async fn create_refresh_token(&self, data: TokenData) -> Result<String> { let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me)); - self.serialize_to_file(&dir, "refresh", TOKEN_LENGTH, data).await + self.serialize_to_file(&dir, "refresh", TOKEN_LENGTH, data) + .await } - async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> { + async fn get_refresh_token( + &self, + website: &url::Url, + token: &str, + ) -> Result<Option<TokenData>> { let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); - match self.deserialize_from_file::<TokenData, _>( - &dir, "refresh", - FileBackend::sanitize_for_path(token).as_ref() - ).await? { + match self + .deserialize_from_file::<TokenData, _>( + &dir, + "refresh", + FileBackend::sanitize_for_path(token).as_ref(), + ) + .await? + { Some((path, _, token)) => { if token.expired() { if let Err(err) = tokio::fs::remove_file(path).await { @@ -286,8 +321,8 @@ impl AuthBackend for FileBackend { } else { Ok(Some(token)) } - }, - None => Ok(None) + } + None => Ok(None), } } @@ -301,57 +336,80 @@ impl AuthBackend for FileBackend { self.path .join(FileBackend::url_to_dir(website)) .join("tokens") - .join(format!("refresh.{}", FileBackend::sanitize_for_path(token))) - ).await { + .join(format!("refresh.{}", FileBackend::sanitize_for_path(token))), + ) + .await + { Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - result => result + result => result, } } // Password management. #[tracing::instrument(skip(password))] async fn verify_password(&self, website: &url::Url, password: String) -> Result<bool> { - use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}}; + use argon2::{ + password_hash::{PasswordHash, PasswordVerifier}, + Argon2, + }; - let password_filename = self.path + let password_filename = self + .path .join(FileBackend::url_to_dir(website)) .join("password"); - tracing::debug!("Reading password for {} from {}", website, password_filename.display()); + tracing::debug!( + "Reading password for {} from {}", + website, + password_filename.display() + ); match tokio::fs::read_to_string(password_filename).await { Ok(password_hash) => { let parsed_hash = { let hash = password_hash.trim(); - #[cfg(debug_assertions)] tracing::debug!("Password hash: {}", hash); - PasswordHash::new(hash) - .expect("Password hash should be valid!") + #[cfg(debug_assertions)] + tracing::debug!("Password hash: {}", hash); + PasswordHash::new(hash).expect("Password hash should be valid!") }; - Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()) - }, - Err(err) => if err.kind() == std::io::ErrorKind::NotFound { - Ok(false) - } else { - Err(err) + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) + } + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + Ok(false) + } else { + Err(err) + } } } } #[tracing::instrument(skip(password))] async fn enroll_password(&self, website: &url::Url, password: String) -> Result<()> { - use argon2::{Argon2, password_hash::{rand_core::OsRng, PasswordHasher, SaltString}}; + use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, + }; - let password_filename = self.path + let password_filename = self + .path .join(FileBackend::url_to_dir(website)) .join("password"); let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - let password_hash = argon2.hash_password(password.as_bytes(), &salt) + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) .expect("Hashing a password should not error out") .to_string(); - tracing::debug!("Enrolling password for {} at {}", website, password_filename.display()); + tracing::debug!( + "Enrolling password for {} at {}", + website, + password_filename.display() + ); tokio::fs::write(password_filename, password_hash.as_bytes()).await } @@ -371,7 +429,7 @@ impl AuthBackend for FileBackend { async fn persist_registration_challenge( &self, website: &url::Url, - state: PasskeyRegistration + state: PasskeyRegistration, ) -> Result<String> { todo!() } @@ -380,7 +438,7 @@ impl AuthBackend for FileBackend { async fn retrieve_registration_challenge( &self, website: &url::Url, - challenge_id: &str + challenge_id: &str, ) -> Result<PasskeyRegistration> { todo!() } @@ -389,7 +447,7 @@ impl AuthBackend for FileBackend { async fn persist_authentication_challenge( &self, website: &url::Url, - state: PasskeyAuthentication + state: PasskeyAuthentication, ) -> Result<String> { todo!() } @@ -398,24 +456,28 @@ impl AuthBackend for FileBackend { async fn retrieve_authentication_challenge( &self, website: &url::Url, - challenge_id: &str + challenge_id: &str, ) -> Result<PasskeyAuthentication> { todo!() } #[tracing::instrument(skip(self))] - async fn list_user_credential_types(&self, website: &url::Url) -> Result<Vec<EnrolledCredential>> { + async fn list_user_credential_types( + &self, + website: &url::Url, + ) -> Result<Vec<EnrolledCredential>> { let mut creds = vec![]; - let password_file = self.path + let password_file = self + .path .join(FileBackend::url_to_dir(website)) .join("password"); tracing::debug!("Password file for {}: {}", website, password_file.display()); - match tokio::fs::metadata(password_file) - .await - { + match tokio::fs::metadata(password_file).await { Ok(_) => creds.push(EnrolledCredential::Password), - Err(err) => if err.kind() != std::io::ErrorKind::NotFound { - return Err(err) + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(err); + } } } diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs index 00ae393..5cdbf05 100644 --- a/src/indieauth/mod.rs +++ b/src/indieauth/mod.rs @@ -1,18 +1,29 @@ -use std::marker::PhantomData; -use microformats::types::Class; -use tracing::error; -use serde::Deserialize; +use crate::database::Storage; use axum::{ - extract::{Form, FromRef, Json, Query, State}, http::StatusCode, response::{Html, IntoResponse, Response} + extract::{Form, FromRef, Json, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, }; #[cfg_attr(not(feature = "webauthn"), allow(unused_imports))] -use axum_extra::extract::{Host, cookie::{CookieJar, Cookie}}; -use axum_extra::{headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader}; -use crate::database::Storage; +use axum_extra::extract::{ + cookie::{Cookie, CookieJar}, + Host, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, + TypedHeader, +}; use kittybox_indieauth::{ - AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest + AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, + GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, + ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, + TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, }; +use microformats::types::Class; +use serde::Deserialize; +use std::marker::PhantomData; use std::str::FromStr; +use tracing::error; pub mod backend; #[cfg(feature = "webauthn")] @@ -41,35 +52,42 @@ impl<A: AuthBackend> std::ops::Deref for User<A> { pub enum IndieAuthResourceError { InvalidRequest, Unauthorized, - InvalidToken + InvalidToken, } impl axum::response::IntoResponse for IndieAuthResourceError { fn into_response(self) -> axum::response::Response { use IndieAuthResourceError::*; match self { - Unauthorized => ( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", "Bearer")] - ).into_response(), + Unauthorized => { + (StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Bearer")]).into_response() + } InvalidRequest => ( StatusCode::BAD_REQUEST, - Json(&serde_json::json!({"error": "invalid_request"})) - ).into_response(), + Json(&serde_json::json!({"error": "invalid_request"})), + ) + .into_response(), InvalidToken => ( StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")], - Json(&serde_json::json!({"error": "not_authorized"})) - ).into_response() + Json(&serde_json::json!({"error": "not_authorized"})), + ) + .into_response(), } } } -impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::OptionalFromRequestParts<St> for User<A> { +impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> + axum::extract::OptionalFromRequestParts<St> for User<A> +{ type Rejection = <Self as axum::extract::FromRequestParts<St>>::Rejection; - async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Option<Self>, Self::Rejection> { - let res = <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await; + async fn from_request_parts( + req: &mut axum::http::request::Parts, + state: &St, + ) -> Result<Option<Self>, Self::Rejection> { + let res = + <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await; match res { Ok(user) => Ok(Some(user)), @@ -79,14 +97,19 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext } } -impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::FromRequestParts<St> for User<A> { +impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> + axum::extract::FromRequestParts<St> for User<A> +{ type Rejection = IndieAuthResourceError; - async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Self, Self::Rejection> { + async fn from_request_parts( + req: &mut axum::http::request::Parts, + state: &St, + ) -> Result<Self, Self::Rejection> { let TypedHeader(Authorization(token)) = TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state) - .await - .map_err(|_| IndieAuthResourceError::Unauthorized)?; + .await + .map_err(|_| IndieAuthResourceError::Unauthorized)?; let auth = A::from_ref(state); @@ -94,10 +117,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext .await .map_err(|_| IndieAuthResourceError::InvalidRequest)?; - auth.get_token( - &format!("https://{host}/").parse().unwrap(), - token.token() - ) + auth.get_token(&format!("https://{host}/").parse().unwrap(), token.token()) .await .unwrap() .ok_or(IndieAuthResourceError::InvalidToken) @@ -105,9 +125,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext } } -pub async fn metadata( - Host(host): Host -) -> Metadata { +pub async fn metadata(Host(host): Host) -> Metadata { let issuer: url::Url = format!("https://{}/", host).parse().unwrap(); let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap(); @@ -117,18 +135,16 @@ pub async fn metadata( token_endpoint: indieauth.join("token").unwrap(), introspection_endpoint: indieauth.join("token_status").unwrap(), introspection_endpoint_auth_methods_supported: Some(vec![ - IntrospectionEndpointAuthMethod::Bearer + IntrospectionEndpointAuthMethod::Bearer, ]), revocation_endpoint: Some(indieauth.join("revoke_token").unwrap()), - revocation_endpoint_auth_methods_supported: Some(vec![ - RevocationEndpointAuthMethod::None - ]), + revocation_endpoint_auth_methods_supported: Some(vec![RevocationEndpointAuthMethod::None]), scopes_supported: Some(vec![ Scope::Create, Scope::Update, Scope::Delete, Scope::Media, - Scope::Profile + Scope::Profile, ]), response_types_supported: Some(vec![ResponseType::Code]), grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]), @@ -142,30 +158,42 @@ pub async fn metadata( async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( Host(host): Host, - Query(request): Query<AuthorizationRequest>, + Query(mut request): Query<AuthorizationRequest>, State(db): State<D>, State(http): State<reqwest_middleware::ClientWithMiddleware>, - State(auth): State<A> + State(auth): State<A>, ) -> Response { let me: url::Url = format!("https://{host}/").parse().unwrap(); // XXX: attempt fetching OAuth application metadata - let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost" && me.domain().unwrap() != "localhost" { + let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost" + && me.domain().unwrap() != "localhost" + { // If client is localhost, but we aren't localhost, generate synthetic metadata. tracing::warn!("Client is localhost, not fetching metadata"); - let mut metadata = ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap(); + let mut metadata = + ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap(); metadata.client_name = Some("Your locally hosted app".to_string()); metadata } else { tracing::debug!("Sending request to {} to fetch metadata", request.client_id); - let metadata_request = http.get(request.client_id.clone()) + let metadata_request = http + .get(request.client_id.clone()) .header("Accept", "application/json, text/html"); - match metadata_request.send().await - .and_then(|res| res.error_for_status() - .map_err(reqwest_middleware::Error::Reqwest)) - { - Ok(response) if response.headers().typed_get::<ContentType>().to_owned().map(mime::Mime::from).map(|m| m.type_() == "text" && m.subtype() == "html").unwrap_or(false) => { + match metadata_request.send().await.and_then(|res| { + res.error_for_status() + .map_err(reqwest_middleware::Error::Reqwest) + }) { + Ok(response) + if response + .headers() + .typed_get::<ContentType>() + .to_owned() + .map(mime::Mime::from) + .map(|m| m.type_() == "text" && m.subtype() == "html") + .unwrap_or(false) => + { let url = response.url().clone(); let text = response.text().await.unwrap(); tracing::debug!("Received {} bytes in response", text.len()); @@ -173,76 +201,97 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( Ok(mf2) => { if let Some(relation) = mf2.rels.items.get(&request.redirect_uri) { if !relation.rels.iter().any(|i| i == "redirect_uri") { - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "The redirect_uri provided was declared as \ - something other than redirect_uri.") - .into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "The redirect_uri provided was declared as \ + something other than redirect_uri.", + ) + .into_response(); } } else if request.redirect_uri.origin() != request.client_id.origin() { - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "The redirect_uri didn't match the origin \ - and wasn't explicitly allowed. You were being tricked.") - .into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "The redirect_uri didn't match the origin \ + and wasn't explicitly allowed. You were being tricked.", + ) + .into_response(); } - - if let Some(app) = mf2.items + // Should we attempt to create synthetic metadata from an h-card? + // + // This would increase compatibility with personal websites. + if let Some(app) = mf2 + .items .iter() - .find(|&i| i.r#type.iter() - .any(|i| { + .find(|&i| { + i.r#type.iter().any(|i| { *i == Class::from_str("h-app").unwrap() || *i == Class::from_str("h-x-app").unwrap() }) - ) + }) .cloned() { // Create a synthetic metadata document. Be forgiving. let mut metadata = ClientMetadata::new( request.client_id.clone(), - app.properties.get("url") + app.properties + .get("url") .and_then(|v| v.first()) .and_then(|i| match i { - microformats::types::PropertyValue::Url(url) => Some(url.clone()), - _ => None + microformats::types::PropertyValue::Url(url) => { + Some(url.clone()) + } + _ => None, }) - .unwrap_or_else(|| request.client_id.clone()) - ).unwrap(); + .unwrap_or_else(|| request.client_id.clone()), + ) + .unwrap(); - metadata.client_name = app.properties.get("name") + metadata.client_name = app + .properties + .get("name") .and_then(|v| v.first()) .and_then(|i| match i { - microformats::types::PropertyValue::Plain(name) => Some(name.to_owned()), - _ => None + microformats::types::PropertyValue::Plain(name) => { + Some(name.to_owned()) + } + _ => None, }); metadata.redirect_uris = mf2.rels.by_rels().remove("redirect_uri"); metadata } else { - return (StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], "No h-app or JSON application metadata found.").into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "No h-app or JSON application metadata found.", + ) + .into_response(); } - }, + } Err(err) => { tracing::error!("Error parsing application metadata: {}", err); return ( StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], - "Parsing h-app metadata failed.").into_response() + "Parsing h-app metadata failed.", + ) + .into_response(); } } - }, + } Ok(response) => match response.json::<ClientMetadata>().await { - Ok(client_metadata) => { - client_metadata - }, + Ok(client_metadata) => client_metadata, Err(err) => { tracing::error!("Error parsing JSON application metadata: {}", err); return ( StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], - format!("Parsing OAuth2 JSON app metadata failed: {}", err) - ).into_response() + format!("Parsing OAuth2 JSON app metadata failed: {}", err), + ) + .into_response(); } }, Err(err) => { @@ -250,27 +299,44 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( return ( StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], - format!("Fetching app metadata failed: {}", err) - ).into_response() + format!("Fetching app metadata failed: {}", err), + ) + .into_response(); } } }; tracing::debug!("Application metadata: {:#?}", h_app); - Html(kittybox_frontend_renderer::Template { - title: "Confirm sign-in via IndieAuth", - blog_name: "Kittybox", - feeds: vec![], - user: None, - content: kittybox_frontend_renderer::AuthorizationRequestPage { - request, - credentials: auth.list_user_credential_types(&me).await.unwrap(), - user: db.get_post(me.as_str()).await.unwrap().unwrap(), - app: h_app - }.to_string(), - }.to_string()) - .into_response() + // Sanity check: some older applications don't ask for scopes when they're supposed to. + // + // Give them the profile scope at least? + if request + .scope + .as_ref() + .map(|scope: &Scopes| scope.is_empty()) + .unwrap_or(true) + { + request.scope.replace(Scopes::new(vec![Scope::Profile])); + } + + Html( + kittybox_frontend_renderer::Template { + title: "Confirm sign-in via IndieAuth", + blog_name: "Kittybox", + feeds: vec![], + user: None, + content: kittybox_frontend_renderer::AuthorizationRequestPage { + request, + credentials: auth.list_user_credential_types(&me).await.unwrap(), + user: db.get_post(me.as_str()).await.unwrap().unwrap(), + app: h_app, + } + .to_string(), + } + .to_string(), + ) + .into_response() } #[derive(Deserialize, Debug)] @@ -278,7 +344,7 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( enum Credential { Password(String), #[cfg(feature = "webauthn")] - WebAuthn(::webauthn::prelude::PublicKeyCredential) + WebAuthn(::webauthn::prelude::PublicKeyCredential), } // The IndieAuth standard doesn't prescribe a format for confirming @@ -291,7 +357,7 @@ enum Credential { #[derive(Deserialize, Debug)] struct AuthorizationConfirmation { authorization_method: Credential, - request: AuthorizationRequest + request: AuthorizationRequest, } #[tracing::instrument(skip(auth, credential))] @@ -299,18 +365,14 @@ async fn verify_credential<A: AuthBackend>( auth: &A, website: &url::Url, credential: Credential, - #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] - challenge_id: Option<&str> + #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] challenge_id: Option<&str>, ) -> std::io::Result<bool> { match credential { Credential::Password(password) => auth.verify_password(website, password).await, #[cfg(feature = "webauthn")] - Credential::WebAuthn(credential) => webauthn::verify( - auth, - website, - credential, - challenge_id.unwrap() - ).await + Credential::WebAuthn(credential) => { + webauthn::verify(auth, website, credential, challenge_id.unwrap()).await + } } } @@ -323,7 +385,8 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( ) -> Response { tracing::debug!("Received authorization confirmation from user"); #[cfg(feature = "webauthn")] - let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE) + let challenge_id = cookies + .get(webauthn::CHALLENGE_ID_COOKIE) .map(|cookie| cookie.value()); #[cfg(not(feature = "webauthn"))] let challenge_id = None; @@ -331,14 +394,16 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( let website = format!("https://{}/", host).parse().unwrap(); let AuthorizationConfirmation { authorization_method: credential, - request: mut auth + request: mut auth, } = confirmation; match verify_credential(&backend, &website, credential, challenge_id).await { - Ok(verified) => if !verified { - error!("User failed verification, bailing out."); - return StatusCode::UNAUTHORIZED.into_response(); - }, + Ok(verified) => { + if !verified { + error!("User failed verification, bailing out."); + return StatusCode::UNAUTHORIZED.into_response(); + } + } Err(err) => { error!("Error while verifying credential: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -365,9 +430,14 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( let location = { let mut uri = redirect_uri; - uri.set_query(Some(&serde_urlencoded::to_string( - AuthorizationResponse { code, state, iss: website } - ).unwrap())); + uri.set_query(Some( + &serde_urlencoded::to_string(AuthorizationResponse { + code, + state, + iss: website, + }) + .unwrap(), + )); uri }; @@ -375,10 +445,11 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( // DO NOT SET `StatusCode::FOUND` here! `fetch()` cannot read from // redirects, it can only follow them or choose to receive an // opaque response instead that is completely useless - (StatusCode::NO_CONTENT, - [("Location", location.as_str())], - #[cfg(feature = "webauthn")] - cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE)) + ( + StatusCode::NO_CONTENT, + [("Location", location.as_str())], + #[cfg(feature = "webauthn")] + cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE)), ) .into_response() } @@ -396,15 +467,18 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( code, client_id, redirect_uri, - code_verifier + code_verifier, } => { let request: AuthorizationRequest = match backend.get_code(&code).await { Ok(Some(request)) => request, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("The provided authorization code is invalid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("The provided authorization code is invalid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving auth request: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -414,51 +488,66 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization code isn't yours.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } if redirect_uri != request.redirect_uri { return Error { kind: ErrorKind::InvalidGrant, - msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()), - error_uri: None - }.into_response() + msg: Some( + "This redirect_uri doesn't match the one the code has been sent to." + .to_string(), + ), + error_uri: None, + } + .into_response(); } if !request.code_challenge.verify(code_verifier) { return Error { kind: ErrorKind::InvalidGrant, msg: Some("The PKCE challenge failed.".to_string()), // are RFCs considered human-readable? 😝 - error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok() - }.into_response() + error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6" + .parse() + .ok(), + } + .into_response(); } let me: url::Url = format!("https://{}/", host).parse().unwrap(); if request.me.unwrap() != me { return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization endpoint does not serve this user.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } - let profile = if request.scope.as_ref() - .map(|s| s.has(&Scope::Profile)) - .unwrap_or_default() + let profile = if request + .scope + .as_ref() + .map(|s| s.has(&Scope::Profile)) + .unwrap_or_default() { match get_profile( db, me.as_str(), - request.scope.as_ref() + request + .scope + .as_ref() .map(|s| s.has(&Scope::Email)) - .unwrap_or_default() - ).await { + .unwrap_or_default(), + ) + .await + { Ok(profile) => { tracing::debug!("Retrieved profile: {:?}", profile); profile - }, + } Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { @@ -466,12 +555,15 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( }; GrantResponse::ProfileUrl(ProfileUrl { me, profile }).into_response() - }, + } _ => Error { kind: ErrorKind::InvalidGrant, msg: Some("The provided grant_type is unusable on this endpoint.".to_string()), - error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code".parse().ok() - }.into_response() + error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code" + .parse() + .ok(), + } + .into_response(), } } @@ -485,36 +577,40 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( #[inline] fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { TokenData { - me, client_id, scope, + me, + client_id, + scope, exp: (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY)) - .as_secs() - .into(), + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY)) + .as_secs() + .into(), iat: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() - .into() + .into(), } } #[inline] fn prepare_refresh_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { TokenData { - me, client_id, scope, + me, + client_id, + scope, exp: (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY)) - .as_secs() - .into(), + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY)) + .as_secs() + .into(), iat: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() - .into() + .into(), } } @@ -525,15 +621,18 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( code, client_id, redirect_uri, - code_verifier + code_verifier, } => { let request: AuthorizationRequest = match backend.get_code(&code).await { Ok(Some(request)) => request, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("The provided authorization code is invalid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("The provided authorization code is invalid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving auth request: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -542,33 +641,46 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( tracing::debug!("Retrieved authorization request: {:?}", request); - let scope = if let Some(scope) = request.scope { scope } else { + let scope = if let Some(scope) = request.scope { + scope + } else { return Error { kind: ErrorKind::InvalidScope, msg: Some("Tokens cannot be issued if no scopes are requested.".to_string()), - error_uri: "https://indieauth.spec.indieweb.org/#access-token-response".parse().ok() - }.into_response(); + error_uri: "https://indieauth.spec.indieweb.org/#access-token-response" + .parse() + .ok(), + } + .into_response(); }; if client_id != request.client_id { return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization code isn't yours.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } if redirect_uri != request.redirect_uri { return Error { kind: ErrorKind::InvalidGrant, - msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()), - error_uri: None - }.into_response() + msg: Some( + "This redirect_uri doesn't match the one the code has been sent to." + .to_string(), + ), + error_uri: None, + } + .into_response(); } if !request.code_challenge.verify(code_verifier) { return Error { kind: ErrorKind::InvalidGrant, msg: Some("The PKCE challenge failed.".to_string()), - error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok() - }.into_response(); + error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6" + .parse() + .ok(), + } + .into_response(); } // Note: we can trust the `request.me` value, since we set @@ -577,30 +689,32 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization endpoint does not serve this user.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } let profile = if dbg!(scope.has(&Scope::Profile)) { - match get_profile( - db, - me.as_str(), - scope.has(&Scope::Email) - ).await { + match get_profile(db, me.as_str(), scope.has(&Scope::Email)).await { Ok(profile) => dbg!(profile), Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { None }; - let access_token = match backend.create_token( - prepare_access_token(me.clone(), client_id.clone(), scope.clone()) - ).await { + let access_token = match backend + .create_token(prepare_access_token( + me.clone(), + client_id.clone(), + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating access token: {}", err); @@ -608,9 +722,10 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( } }; // TODO: only create refresh token if user allows it - let refresh_token = match backend.create_refresh_token( - prepare_refresh_token(me.clone(), client_id, scope.clone()) - ).await { + let refresh_token = match backend + .create_refresh_token(prepare_refresh_token(me.clone(), client_id, scope.clone())) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating refresh token: {}", err); @@ -626,24 +741,28 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( scope: Some(scope), expires_in: Some(ACCESS_TOKEN_VALIDITY), refresh_token: Some(refresh_token), - state: None - }.into_response() - }, + state: None, + } + .into_response() + } GrantRequest::RefreshToken { refresh_token, client_id, - scope + scope, } => { let data = match backend.get_refresh_token(&me, &refresh_token).await { Ok(Some(token)) => token, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("This refresh token is not valid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("This refresh token is not valid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; @@ -651,17 +770,22 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This refresh token is not yours.".to_string()), - error_uri: None - }.into_response(); + error_uri: None, + } + .into_response(); } let scope = if let Some(scope) = scope { if !data.scope.has_all(scope.as_ref()) { return Error { kind: ErrorKind::InvalidScope, - msg: Some("You can't request additional scopes through the refresh token grant.".to_string()), - error_uri: None - }.into_response(); + msg: Some( + "You can't request additional scopes through the refresh token grant." + .to_string(), + ), + error_uri: None, + } + .into_response(); } scope @@ -670,27 +794,27 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( data.scope }; - let profile = if scope.has(&Scope::Profile) { - match get_profile( - db, - data.me.as_str(), - scope.has(&Scope::Email) - ).await { + match get_profile(db, data.me.as_str(), scope.has(&Scope::Email)).await { Ok(profile) => profile, Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { None }; - let access_token = match backend.create_token( - prepare_access_token(data.me.clone(), client_id.clone(), scope.clone()) - ).await { + let access_token = match backend + .create_token(prepare_access_token( + data.me.clone(), + client_id.clone(), + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating access token: {}", err); @@ -699,9 +823,14 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( }; let old_refresh_token = refresh_token; - let refresh_token = match backend.create_refresh_token( - prepare_refresh_token(data.me.clone(), client_id, scope.clone()) - ).await { + let refresh_token = match backend + .create_refresh_token(prepare_refresh_token( + data.me.clone(), + client_id, + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating refresh token: {}", err); @@ -721,8 +850,9 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( scope: Some(scope), expires_in: Some(ACCESS_TOKEN_VALIDITY), refresh_token: Some(refresh_token), - state: None - }.into_response() + state: None, + } + .into_response() } } } @@ -740,26 +870,39 @@ async fn introspection_endpoint_post<A: AuthBackend>( // Check authentication first match backend.get_token(&me, auth_token.token()).await { - Ok(Some(token)) => if !token.scope.has(&Scope::custom(KITTYBOX_TOKEN_STATUS)) { - return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope - }))).into_response(); - }, - Ok(None) => return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": kittybox_indieauth::ResourceErrorKind::InvalidToken - }))).into_response(), + Ok(Some(token)) => { + if !token.scope.has(&Scope::custom(KITTYBOX_TOKEN_STATUS)) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope + })), + ) + .into_response(); + } + } + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InvalidToken + })), + ) + .into_response() + } Err(err) => { tracing::error!("Error retrieving token data for introspection: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } - let response: TokenIntrospectionResponse = match backend.get_token(&me, &token_request.token).await { - Ok(maybe_data) => maybe_data.into(), - Err(err) => { - tracing::error!("Error retrieving token data: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; + let response: TokenIntrospectionResponse = + match backend.get_token(&me, &token_request.token).await { + Ok(maybe_data) => maybe_data.into(), + Err(err) => { + tracing::error!("Error retrieving token data: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; response.into_response() } @@ -787,7 +930,7 @@ async fn revocation_endpoint_post<A: AuthBackend>( async fn get_profile<D: Storage + 'static>( db: D, url: &str, - email: bool + email: bool, ) -> crate::database::Result<Option<Profile>> { fn get_first(v: serde_json::Value) -> Option<String> { match v { @@ -796,10 +939,10 @@ async fn get_profile<D: Storage + 'static>( match a.pop() { Some(serde_json::Value::String(s)) => Some(s), Some(serde_json::Value::Object(mut o)) => o.remove("value").and_then(get_first), - _ => None + _ => None, } - }, - _ => None + } + _ => None, } } @@ -807,15 +950,26 @@ async fn get_profile<D: Storage + 'static>( // Ruthlessly manually destructure the MF2 document to save memory let mut properties = match mf2.as_object_mut().unwrap().remove("properties") { Some(serde_json::Value::Object(props)) => props, - _ => unreachable!() + _ => unreachable!(), }; drop(mf2); let name = properties.remove("name").and_then(get_first); - let url = properties.remove("uid").and_then(get_first).and_then(|u| u.parse().ok()); - let photo = properties.remove("photo").and_then(get_first).and_then(|u| u.parse().ok()); + let url = properties + .remove("uid") + .and_then(get_first) + .and_then(|u| u.parse().ok()); + let photo = properties + .remove("photo") + .and_then(get_first) + .and_then(|u| u.parse().ok()); let email = properties.remove("name").and_then(get_first); - Profile { name, url, photo, email } + Profile { + name, + url, + photo, + email, + } })) } @@ -823,7 +977,7 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, State(backend): State<A>, - State(db): State<D> + State(db): State<D>, ) -> Response { use serde_json::json; @@ -832,14 +986,22 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( match backend.get_token(&me, auth_token.token()).await { Ok(Some(token)) => { if token.expired() { - return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": kittybox_indieauth::ResourceErrorKind::InvalidToken - }))).into_response(); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InvalidToken + })), + ) + .into_response(); } if !token.scope.has(&Scope::Profile) { - return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope - }))).into_response(); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope + })), + ) + .into_response(); } match get_profile(db, me.as_str(), token.scope.has(&Scope::Email)).await { @@ -847,17 +1009,19 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( Ok(None) => Json(json!({ // We do this because ResourceErrorKind is IndieAuth errors only "error": "invalid_request" - })).into_response(), + })) + .into_response(), Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } - }, + } Ok(None) => Json(json!({ "error": kittybox_indieauth::ResourceErrorKind::InvalidToken - })).into_response(), + })) + .into_response(), Err(err) => { tracing::error!("Error reading token: {}", err); @@ -871,57 +1035,51 @@ where S: Storage + FromRef<St> + 'static, A: AuthBackend + FromRef<St>, reqwest_middleware::ClientWithMiddleware: FromRef<St>, - St: Clone + Send + Sync + 'static + St: Clone + Send + Sync + 'static, { - use axum::routing::{Router, get, post}; + use axum::routing::{get, post, Router}; Router::new() .nest( "/.kittybox/indieauth", Router::new() - .route("/metadata", - get(metadata)) + .route("/metadata", get(metadata)) .route( "/auth", get(authorization_endpoint_get::<A, S>) - .post(authorization_endpoint_post::<A, S>)) - .route( - "/auth/confirm", - post(authorization_endpoint_confirm::<A>)) - .route( - "/token", - post(token_endpoint_post::<A, S>)) - .route( - "/token_status", - post(introspection_endpoint_post::<A>)) - .route( - "/revoke_token", - post(revocation_endpoint_post::<A>)) + .post(authorization_endpoint_post::<A, S>), + ) + .route("/auth/confirm", post(authorization_endpoint_confirm::<A>)) + .route("/token", post(token_endpoint_post::<A, S>)) + .route("/token_status", post(introspection_endpoint_post::<A>)) + .route("/revoke_token", post(revocation_endpoint_post::<A>)) + .route("/userinfo", get(userinfo_endpoint_get::<A, S>)) .route( - "/userinfo", - get(userinfo_endpoint_get::<A, S>)) - - .route("/webauthn/pre_register", - get( - #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, S>, - #[cfg(not(feature = "webauthn"))] || std::future::ready(axum::http::StatusCode::NOT_FOUND) - ) + "/webauthn/pre_register", + get( + #[cfg(feature = "webauthn")] + webauthn::webauthn_pre_register::<A, S>, + #[cfg(not(feature = "webauthn"))] + || std::future::ready(axum::http::StatusCode::NOT_FOUND), + ), ) - .layer(tower_http::cors::CorsLayer::new() - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST - ]) - .allow_origin(tower_http::cors::Any)) + .layer( + tower_http::cors::CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin(tower_http::cors::Any), + ), ) .route( "/.well-known/oauth-authorization-server", - get(|| std::future::ready( - (StatusCode::FOUND, - [("Location", - "/.kittybox/indieauth/metadata")] - ).into_response() - )) + get(|| { + std::future::ready( + ( + StatusCode::FOUND, + [("Location", "/.kittybox/indieauth/metadata")], + ) + .into_response(), + ) + }), ) } @@ -929,9 +1087,10 @@ where mod tests { #[test] fn test_deserialize_authorization_confirmation() { - use super::{Credential, AuthorizationConfirmation}; + use super::{AuthorizationConfirmation, Credential}; - let confirmation = serde_json::from_str::<AuthorizationConfirmation>(r#"{ + let confirmation = serde_json::from_str::<AuthorizationConfirmation>( + r#"{ "request":{ "response_type": "code", "client_id": "https://quill.p3k.io/", @@ -942,12 +1101,14 @@ mod tests { "scope": "create+media" }, "authorization_method": "swordfish" - }"#).unwrap(); + }"#, + ) + .unwrap(); match confirmation.authorization_method { Credential::Password(password) => assert_eq!(password.as_str(), "swordfish"), #[allow(unreachable_patterns)] - other => panic!("Incorrect credential: {:?}", other) + other => panic!("Incorrect credential: {:?}", other), } assert_eq!(confirmation.request.state.as_ref(), "10101010"); } diff --git a/src/indieauth/webauthn.rs b/src/indieauth/webauthn.rs index 0757e72..80d210c 100644 --- a/src/indieauth/webauthn.rs +++ b/src/indieauth/webauthn.rs @@ -1,10 +1,17 @@ use axum::{ extract::Json, + http::StatusCode, response::{IntoResponse, Response}, - http::StatusCode, Extension + Extension, +}; +use axum_extra::extract::{ + cookie::{Cookie, CookieJar}, + Host, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, }; -use axum_extra::extract::{Host, cookie::{CookieJar, Cookie}}; -use axum_extra::{TypedHeader, headers::{authorization::Bearer, Authorization}}; use super::backend::AuthBackend; use crate::database::Storage; @@ -12,40 +19,33 @@ use crate::database::Storage; pub(crate) const CHALLENGE_ID_COOKIE: &str = "kittybox_webauthn_challenge_id"; macro_rules! bail { - ($msg:literal, $err:expr) => { - { - ::tracing::error!($msg, $err); - return ::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + ($msg:literal, $err:expr) => {{ + ::tracing::error!($msg, $err); + return ::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }}; } pub async fn webauthn_pre_register<A: AuthBackend, D: Storage + 'static>( Host(host): Host, Extension(db): Extension<D>, Extension(auth): Extension<A>, - cookies: CookieJar + cookies: CookieJar, ) -> Response { let uid = format!("https://{}/", host.clone()); let uid_url: url::Url = uid.parse().unwrap(); // This will not find an h-card in onboarding! let display_name = match db.get_post(&uid).await { Ok(hcard) => match hcard { - Some(mut hcard) => { - match hcard["properties"]["uid"][0].take() { - serde_json::Value::String(name) => name, - _ => String::default() - } + Some(mut hcard) => match hcard["properties"]["uid"][0].take() { + serde_json::Value::String(name) => name, + _ => String::default(), }, - None => String::default() + None => String::default(), }, - Err(err) => bail!("Error retrieving h-card: {}", err) + Err(err) => bail!("Error retrieving h-card: {}", err), }; - let webauthn = webauthn::WebauthnBuilder::new( - &host, - &uid_url - ) + let webauthn = webauthn::WebauthnBuilder::new(&host, &uid_url) .unwrap() .rp_name("Kittybox") .build() @@ -58,10 +58,10 @@ pub async fn webauthn_pre_register<A: AuthBackend, D: Storage + 'static>( webauthn::prelude::Uuid::nil(), &uid, &display_name, - Some(vec![]) + Some(vec![]), ) { Ok((challenge, state)) => (challenge, state), - Err(err) => bail!("Error generating WebAuthn registration data: {}", err) + Err(err) => bail!("Error generating WebAuthn registration data: {}", err), }; match auth.persist_registration_challenge(&uid_url, state).await { @@ -69,11 +69,12 @@ pub async fn webauthn_pre_register<A: AuthBackend, D: Storage + 'static>( cookies.add( Cookie::build((CHALLENGE_ID_COOKIE, challenge_id)) .secure(true) - .finish() + .finish(), ), - Json(challenge) - ).into_response(), - Err(err) => bail!("Failed to persist WebAuthn challenge: {}", err) + Json(challenge), + ) + .into_response(), + Err(err) => bail!("Failed to persist WebAuthn challenge: {}", err), } } @@ -82,39 +83,36 @@ pub async fn webauthn_register<A: AuthBackend>( Json(credential): Json<webauthn::prelude::RegisterPublicKeyCredential>, // TODO determine if we can use a cookie maybe? user_credential: Option<TypedHeader<Authorization<Bearer>>>, - Extension(auth): Extension<A> + Extension(auth): Extension<A>, ) -> Response { let uid = format!("https://{}/", host.clone()); let uid_url: url::Url = uid.parse().unwrap(); let pubkeys = match auth.list_webauthn_pubkeys(&uid_url).await { Ok(pubkeys) => pubkeys, - Err(err) => bail!("Error enumerating existing WebAuthn credentials: {}", err) + Err(err) => bail!("Error enumerating existing WebAuthn credentials: {}", err), }; if !pubkeys.is_empty() { if let Some(TypedHeader(Authorization(token))) = user_credential { // TODO check validity of the credential } else { - return StatusCode::UNAUTHORIZED.into_response() + return StatusCode::UNAUTHORIZED.into_response(); } } - return StatusCode::OK.into_response() + return StatusCode::OK.into_response(); } pub(crate) async fn verify<A: AuthBackend>( auth: &A, website: &url::Url, credential: webauthn::prelude::PublicKeyCredential, - challenge_id: &str + challenge_id: &str, ) -> std::io::Result<bool> { let host = website.host_str().unwrap(); - let webauthn = webauthn::WebauthnBuilder::new( - host, - website - ) + let webauthn = webauthn::WebauthnBuilder::new(host, website) .unwrap() .rp_name("Kittybox") .build() @@ -122,12 +120,14 @@ pub(crate) async fn verify<A: AuthBackend>( match webauthn.finish_passkey_authentication( &credential, - &auth.retrieve_authentication_challenge(&website, challenge_id).await? + &auth + .retrieve_authentication_challenge(&website, challenge_id) + .await?, ) { Err(err) => { tracing::error!("WebAuthn error: {}", err); Ok(false) - }, + } Ok(authentication_result) => { let counter = authentication_result.counter(); let cred_id = authentication_result.cred_id(); diff --git a/src/lib.rs b/src/lib.rs index 6d8e784..0df5e5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,22 +4,28 @@ use std::sync::Arc; use axum::extract::{FromRef, FromRequestParts, OptionalFromRequestParts}; -use axum_extra::extract::{cookie::{Cookie, Key}, SignedCookieJar}; +use axum_extra::extract::{ + cookie::{Cookie, Key}, + SignedCookieJar, +}; use database::{FileStorage, PostgresStorage, Storage}; use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend}; use kittybox_util::queue::JobQueue; -use media::storage::{MediaStore, file::FileStore as FileMediaStore}; -use tokio::{sync::{Mutex, RwLock}, task::JoinSet}; +use media::storage::{file::FileStore as FileMediaStore, MediaStore}; +use tokio::{ + sync::{Mutex, RwLock}, + task::JoinSet, +}; use webmentions::queue::PostgresJobQueue; /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. pub mod database; pub mod frontend; +pub mod indieauth; +pub mod login; pub mod media; pub mod micropub; -pub mod indieauth; pub mod webmentions; -pub mod login; //pub mod admin; const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8"; @@ -27,10 +33,10 @@ const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8"; #[derive(Clone)] pub struct AppState<A, S, M, Q> where -A: AuthBackend + Sized + 'static, -S: Storage + Sized + 'static, -M: MediaStore + Sized + 'static, -Q: JobQueue<webmentions::Webmention> + Sized + A: AuthBackend + Sized + 'static, + S: Storage + Sized + 'static, + M: MediaStore + Sized + 'static, + Q: JobQueue<webmentions::Webmention> + Sized, { pub auth_backend: A, pub storage: S, @@ -39,7 +45,7 @@ Q: JobQueue<webmentions::Webmention> + Sized pub http: reqwest_middleware::ClientWithMiddleware, pub background_jobs: Arc<Mutex<JoinSet<()>>>, pub cookie_key: Key, - pub session_store: SessionStore + pub session_store: SessionStore, } pub type SessionStore = Arc<RwLock<std::collections::HashMap<uuid::Uuid, Session>>>; @@ -60,7 +66,11 @@ pub struct NoSessionError; impl axum::response::IntoResponse for NoSessionError { fn into_response(self) -> axum::response::Response { // TODO: prettier error message - (axum::http::StatusCode::UNAUTHORIZED, "You are not logged in, but this page requires a session.").into_response() + ( + axum::http::StatusCode::UNAUTHORIZED, + "You are not logged in, but this page requires a session.", + ) + .into_response() } } @@ -72,11 +82,17 @@ where { type Rejection = std::convert::Infallible; - async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result<Option<Self>, Self::Rejection> { - let jar = SignedCookieJar::<Key>::from_request_parts(parts, state).await.unwrap(); + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result<Option<Self>, Self::Rejection> { + let jar = SignedCookieJar::<Key>::from_request_parts(parts, state) + .await + .unwrap(); let session_store = SessionStore::from_ref(state).read_owned().await; - Ok(jar.get("session_id") + Ok(jar + .get("session_id") .as_ref() .map(Cookie::value_trimmed) .and_then(|id| uuid::Uuid::parse_str(id).ok()) @@ -103,7 +119,10 @@ where // have to repeat this magic invocation. impl<S, M, Q> FromRef<AppState<Self, S, M, Q>> for FileAuthBackend -where S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + S: Storage, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<Self, S, M, Q>) -> Self { input.auth_backend.clone() @@ -111,7 +130,10 @@ where S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> } impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for PostgresStorage -where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, Self, M, Q>) -> Self { input.storage.clone() @@ -119,7 +141,10 @@ where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention> } impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for FileStorage -where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, Self, M, Q>) -> Self { input.storage.clone() @@ -128,7 +153,10 @@ where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention> impl<A, S, Q> FromRef<AppState<A, S, Self, Q>> for FileMediaStore // where A: AuthBackend, S: Storage -where A: AuthBackend, S: Storage, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + S: Storage, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, S, Self, Q>) -> Self { input.media_store.clone() @@ -136,7 +164,11 @@ where A: AuthBackend, S: Storage, Q: JobQueue<webmentions::Webmention> } impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Key -where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + S: Storage, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, S, M, Q>) -> Self { input.cookie_key.clone() @@ -144,7 +176,11 @@ where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmen } impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for reqwest_middleware::ClientWithMiddleware -where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + S: Storage, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, S, M, Q>) -> Self { input.http.clone() @@ -152,7 +188,11 @@ where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmen } impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for Arc<Mutex<JoinSet<()>>> -where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + S: Storage, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, S, M, Q>) -> Self { input.background_jobs.clone() @@ -161,7 +201,10 @@ where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmen #[cfg(feature = "sqlx")] impl<A, S, M> FromRef<AppState<A, S, M, Self>> for PostgresJobQueue<webmentions::Webmention> -where A: AuthBackend, S: Storage, M: MediaStore +where + A: AuthBackend, + S: Storage, + M: MediaStore, { fn from_ref(input: &AppState<A, S, M, Self>) -> Self { input.job_queue.clone() @@ -169,127 +212,58 @@ where A: AuthBackend, S: Storage, M: MediaStore } impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for SessionStore -where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +where + A: AuthBackend, + S: Storage, + M: MediaStore, + Q: JobQueue<webmentions::Webmention>, { fn from_ref(input: &AppState<A, S, M, Q>) -> Self { input.session_store.clone() } } -pub mod companion { - use std::{collections::HashMap, sync::Arc}; - use axum::{ - extract::{Extension, Path}, - response::{IntoResponse, Response} - }; - - #[derive(Debug, Clone, Copy)] - struct Resource { - data: &'static [u8], - mime: &'static str - } - - impl IntoResponse for &Resource { - fn into_response(self) -> Response { - (axum::http::StatusCode::OK, - [("Content-Type", self.mime)], - self.data).into_response() - } - } - - // TODO replace with the "phf" crate someday - type ResourceTable = Arc<HashMap<&'static str, Resource>>; - - #[tracing::instrument] - async fn map_to_static( - Path(name): Path<String>, - Extension(resources): Extension<ResourceTable> - ) -> Response { - tracing::debug!("Searching for {} in the resource table...", name); - match resources.get(name.as_str()) { - Some(res) => res.into_response(), - None => { - #[cfg(debug_assertions)] tracing::error!("Not found"); - - (axum::http::StatusCode::NOT_FOUND, - [("Content-Type", "text/plain")], - "Not found. Sorry.".as_bytes()).into_response() - } - } - } - - pub fn router<St: Clone + Send + Sync + 'static>() -> axum::Router<St> { - let resources: ResourceTable = { - let mut map = HashMap::new(); - - macro_rules! register_resource { - ($map:ident, $prefix:expr, ($filename:literal, $mime:literal)) => {{ - $map.insert($filename, Resource { - data: include_bytes!(concat!($prefix, $filename)), - mime: $mime - }) - }}; - ($map:ident, $prefix:expr, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{ - register_resource!($map, $prefix, ($filename, $mime)); - register_resource!($map, $prefix, $(($f, $m)),+); - }}; - } - - register_resource! { - map, - concat!(env!("OUT_DIR"), "/", "companion", "/"), - ("index.html", "text/html; charset=\"utf-8\""), - ("main.js", "text/javascript"), - ("micropub_api.js", "text/javascript"), - ("indieauth.js", "text/javascript"), - ("base64.js", "text/javascript"), - ("style.css", "text/css") - }; - - Arc::new(map) - }; - - axum::Router::new() - .route( - "/{filename}", - axum::routing::get(map_to_static) - .layer(Extension(resources)) - ) - } -} +pub mod companion; async fn teapot_route() -> impl axum::response::IntoResponse { use axum::http::{header, StatusCode}; - (StatusCode::IM_A_TEAPOT, [(header::CONTENT_TYPE, "text/plain")], "Sorry, can't brew coffee yet!") + ( + StatusCode::IM_A_TEAPOT, + [(header::CONTENT_TYPE, "text/plain")], + "Sorry, can't brew coffee yet!", + ) } async fn health_check<D>( axum::extract::State(data): axum::extract::State<D>, ) -> impl axum::response::IntoResponse where - D: crate::database::Storage + D: crate::database::Storage, { (axum::http::StatusCode::OK, std::borrow::Cow::Borrowed("OK")) } pub async fn compose_kittybox<St, A, S, M, Q>() -> axum::Router<St> where -A: AuthBackend + 'static + FromRef<St>, -S: Storage + 'static + FromRef<St>, -M: MediaStore + 'static + FromRef<St>, -Q: kittybox_util::queue::JobQueue<crate::webmentions::Webmention> + FromRef<St>, -reqwest_middleware::ClientWithMiddleware: FromRef<St>, -Arc<Mutex<JoinSet<()>>>: FromRef<St>, -crate::SessionStore: FromRef<St>, -axum_extra::extract::cookie::Key: FromRef<St>, -St: Clone + Send + Sync + 'static + A: AuthBackend + 'static + FromRef<St>, + S: Storage + 'static + FromRef<St>, + M: MediaStore + 'static + FromRef<St>, + Q: kittybox_util::queue::JobQueue<crate::webmentions::Webmention> + FromRef<St>, + reqwest_middleware::ClientWithMiddleware: FromRef<St>, + Arc<Mutex<JoinSet<()>>>: FromRef<St>, + crate::SessionStore: FromRef<St>, + axum_extra::extract::cookie::Key: FromRef<St>, + St: Clone + Send + Sync + 'static, { use axum::routing::get; axum::Router::new() .route("/", get(crate::frontend::homepage::<S>)) .fallback(get(crate::frontend::catchall::<S>)) .route("/.kittybox/micropub", crate::micropub::router::<A, S, St>()) - .route("/.kittybox/onboarding", crate::frontend::onboarding::router::<St, S>()) + .route( + "/.kittybox/onboarding", + crate::frontend::onboarding::router::<St, S>(), + ) .nest("/.kittybox/media", crate::media::router::<St, A, M>()) .merge(crate::indieauth::router::<St, A, S>()) .merge(crate::webmentions::router::<St, Q>()) @@ -297,21 +271,38 @@ St: Clone + Send + Sync + 'static .nest("/.kittybox/login", crate::login::router::<St, S>()) .route( "/.kittybox/static/{*path}", - axum::routing::get(crate::frontend::statics) + axum::routing::get(crate::frontend::statics), ) .route("/.kittybox/coffee", get(teapot_route)) - .nest("/.kittybox/micropub/client", crate::companion::router::<St>()) + .nest( + "/.kittybox/micropub/client", + crate::companion::router::<St>(), + ) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::catch_panic::CatchPanicLayer::new()) - .layer(tower_http::sensitive_headers::SetSensitiveHeadersLayer::new([ - axum::http::header::AUTHORIZATION, - axum::http::header::COOKIE, - axum::http::header::SET_COOKIE, - ])) + .layer( + tower_http::sensitive_headers::SetSensitiveHeadersLayer::new([ + axum::http::header::AUTHORIZATION, + axum::http::header::COOKIE, + axum::http::header::SET_COOKIE, + ]), + ) .layer(tower_http::set_header::SetResponseHeaderLayer::appending( axum::http::header::CONTENT_SECURITY_POLICY, - axum::http::HeaderValue::from_static( - "default-src 'self'; img-src https:; script-src 'self'; style-src 'self'; base-uri 'none'; object-src 'none'" - ) + axum::http::HeaderValue::from_static(concat!( + "default-src 'none';", // Do not allow unknown things we didn't foresee. + "img-src https:;", // Allow hotlinking images from anywhere. + "form-action 'self';", // Only allow sending forms back to us. + "media-src 'self';", // Only allow embedding media from us. + "script-src 'self';", // Only run scripts we serve. + "font-src 'self';", // Only use fonts we serve. + "style-src 'self';", // Only use styles we serve. + "base-uri 'none';", // Do not allow to change the base URI. + "object-src 'none';", // Do not allow to embed objects (Flash/ActiveX). + "connect-src 'self';", // Allow sending data back to us. (WHY IS THIS A THING OMG) + // Allow embedding the Bandcamp player for jam posts. + // TODO: perhaps make this policy customizable?… + "frame-src 'self' https://bandcamp.com/EmbeddedPlayer/;" + )), )) } diff --git a/src/login.rs b/src/login.rs index eaa787c..3038d9c 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,10 +1,25 @@ use std::{borrow::Cow, str::FromStr}; +use axum::{ + extract::{FromRef, Query, State}, + http::HeaderValue, + response::IntoResponse, + Form, +}; +use axum_extra::{ + extract::{ + cookie::{self, Cookie}, + Host, SignedCookieJar, + }, + headers::HeaderMapExt, + TypedHeader, +}; use futures_util::FutureExt; -use axum::{extract::{FromRef, Query, State}, http::HeaderValue, response::IntoResponse, Form}; -use axum_extra::{extract::{Host, cookie::{self, Cookie}, SignedCookieJar}, headers::HeaderMapExt, TypedHeader}; -use hyper::{header::{CACHE_CONTROL, LOCATION}, StatusCode}; -use kittybox_frontend_renderer::{Template, LoginPage, LogoutPage}; +use hyper::{ + header::{CACHE_CONTROL, LOCATION}, + StatusCode, +}; +use kittybox_frontend_renderer::{LoginPage, LogoutPage, Template}; use kittybox_indieauth::{AuthorizationResponse, Error, GrantType, PKCEVerifier, Scope, Scopes}; use sha2::Digest; @@ -13,14 +28,13 @@ use crate::database::Storage; /// Show a login page. async fn get<S: Storage + Send + Sync + 'static>( State(db): State<S>, - Host(host): Host + Host(host): Host, ) -> impl axum::response::IntoResponse { let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); let (blogname, channels) = tokio::join!( db.get_setting::<crate::database::settings::SiteName>(&hcard_url) - .map(Result::unwrap_or_default), - + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); ( @@ -34,14 +48,15 @@ async fn get<S: Storage + Send + Sync + 'static>( blog_name: blogname.as_ref(), feeds: channels, user: None, - content: LoginPage {}.to_string() - }.to_string() + content: LoginPage {}.to_string(), + } + .to_string(), ) } #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] struct LoginForm { - url: url::Url + url: url::Url, } /// Accept login and start the IndieAuth dance. @@ -60,10 +75,12 @@ async fn post( .expires(None) .secure(true) .http_only(true) - .build() + .build(), ); - let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); + let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host) + .parse() + .unwrap(); let redirect_uri = { let mut uri = client_id.clone(); uri.set_path("/.kittybox/login/finish"); @@ -71,11 +88,15 @@ async fn post( }; let indieauth_state = kittybox_indieauth::AuthorizationRequest { response_type: kittybox_indieauth::ResponseType::Code, - client_id, redirect_uri, + client_id, + redirect_uri, state: kittybox_indieauth::State::new(), - code_challenge: kittybox_indieauth::PKCEChallenge::new(&code_verifier, kittybox_indieauth::PKCEMethod::S256), + code_challenge: kittybox_indieauth::PKCEChallenge::new( + &code_verifier, + kittybox_indieauth::PKCEMethod::S256, + ), scope: Some(Scopes::new(vec![Scope::Profile])), - me: Some(form.url.clone()) + me: Some(form.url.clone()), }; // Fetch the user's homepage, determine their authorization endpoint @@ -89,8 +110,9 @@ async fn post( tracing::error!("Error fetching homepage: {:?}", err); return ( StatusCode::BAD_REQUEST, - format!("couldn't fetch your homepage: {}", err) - ).into_response() + format!("couldn't fetch your homepage: {}", err), + ) + .into_response(); } }; @@ -106,22 +128,27 @@ async fn post( // .collect::<Vec<axum_extra::headers::Link>>(); // // todo!("parse Link: headers") - + let body = match response.text().await { Ok(body) => match microformats::from_html(&body, form.url) { Ok(mf2) => mf2, - Err(err) => return ( - StatusCode::BAD_REQUEST, - format!("error while parsing your homepage with mf2: {}", err) - ).into_response() + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + format!("error while parsing your homepage with mf2: {}", err), + ) + .into_response() + } }, - Err(err) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("error while fetching your homepage: {}", err) - ).into_response() + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("error while fetching your homepage: {}", err), + ) + .into_response() + } }; - let mut iss: Option<url::Url> = None; let mut authorization_endpoint = match body .rels @@ -139,10 +166,22 @@ async fn post( Ok(metadata) => { iss = Some(metadata.issuer); metadata.authorization_endpoint - }, - Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't parse your oauth2 metadata: {}", err)).into_response() + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("couldn't parse your oauth2 metadata: {}", err), + ) + .into_response() + } }, - Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't fetch your oauth2 metadata: {}", err)).into_response() + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("couldn't fetch your oauth2 metadata: {}", err), + ) + .into_response() + } }, None => match body .rels @@ -151,13 +190,17 @@ async fn post( .map(|v| v.as_slice()) .unwrap_or_default() .first() - .cloned() { - Some(authorization_endpoint) => authorization_endpoint, - None => return ( + .cloned() + { + Some(authorization_endpoint) => authorization_endpoint, + None => { + return ( StatusCode::BAD_REQUEST, - "no authorization endpoint was found on your homepage." - ).into_response() + "no authorization endpoint was found on your homepage.", + ) + .into_response() } + }, }; cookies = cookies.add( @@ -166,7 +209,7 @@ async fn post( .expires(None) .secure(true) .http_only(true) - .build() + .build(), ); if let Some(iss) = iss { @@ -176,7 +219,7 @@ async fn post( .expires(None) .secure(true) .http_only(true) - .build() + .build(), ); } @@ -186,7 +229,7 @@ async fn post( .expires(None) .secure(true) .http_only(true) - .build() + .build(), ); authorization_endpoint @@ -194,9 +237,12 @@ async fn post( .extend_pairs(indieauth_state.as_query_pairs().iter()); tracing::debug!("Forwarding user to {}", authorization_endpoint); - (StatusCode::FOUND, [ - ("Location", authorization_endpoint.to_string()), - ], cookies).into_response() + ( + StatusCode::FOUND, + [("Location", authorization_endpoint.to_string())], + cookies, + ) + .into_response() } /// Accept the return of the IndieAuth dance. Set a cookie for the @@ -208,7 +254,9 @@ async fn callback( State(http): State<reqwest_middleware::ClientWithMiddleware>, State(session_store): State<crate::SessionStore>, ) -> axum::response::Response { - let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); + let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host) + .parse() + .unwrap(); let redirect_uri = { let mut uri = client_id.clone(); uri.set_path("/.kittybox/login/finish"); @@ -218,7 +266,8 @@ async fn callback( let me: url::Url = cookie_jar.get("me").unwrap().value().parse().unwrap(); let code_verifier: PKCEVerifier = cookie_jar.get("code_verifier").unwrap().value().into(); - let authorization_endpoint: url::Url = cookie_jar.get("authorization_endpoint") + let authorization_endpoint: url::Url = cookie_jar + .get("authorization_endpoint") .and_then(|v| v.value().parse().ok()) .unwrap(); match cookie_jar.get("iss").and_then(|c| c.value().parse().ok()) { @@ -232,24 +281,59 @@ async fn callback( code: response.code, client_id, redirect_uri, - code_verifier, + code_verifier, }; - tracing::debug!("POSTing {:?} to authorization endpoint {}", grant_request, authorization_endpoint); - let res = match http.post(authorization_endpoint) + tracing::debug!( + "POSTing {:?} to authorization endpoint {}", + grant_request, + authorization_endpoint + ); + let res = match http + .post(authorization_endpoint) .form(&grant_request) .header(reqwest::header::ACCEPT, "application/json") .send() .await { - Ok(res) if res.status().is_success() => match res.json::<kittybox_indieauth::GrantResponse>().await { - Ok(grant) => grant, - Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing authorization endpoint response: {}", err)).into_response() - }, + Ok(res) if res.status().is_success() => { + match res.json::<kittybox_indieauth::GrantResponse>().await { + Ok(grant) => grant, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CACHE_CONTROL, "no-store")], + format!("error parsing authorization endpoint response: {}", err), + ) + .into_response() + } + } + } Ok(res) => match res.json::<Error>().await { - Ok(err) => return (StatusCode::BAD_REQUEST, [(CACHE_CONTROL, "no-store")], err.to_string()).into_response(), - Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing indieauth error: {}", err)).into_response() + Ok(err) => { + return ( + StatusCode::BAD_REQUEST, + [(CACHE_CONTROL, "no-store")], + err.to_string(), + ) + .into_response() + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CACHE_CONTROL, "no-store")], + format!("error parsing indieauth error: {}", err), + ) + .into_response() + } + }, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CACHE_CONTROL, "no-store")], + format!("error redeeming authorization code: {}", err), + ) + .into_response() } - Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error redeeming authorization code: {}", err)).into_response() }; let profile = match res { @@ -265,19 +349,28 @@ async fn callback( let uuid = uuid::Uuid::new_v4(); session_store.write().await.insert(uuid, session); let cookies = cookie_jar - .add(Cookie::build(("session_id", uuid.to_string())) - .expires(None) - .secure(true) - .http_only(true) - .path("/") - .build() + .add( + Cookie::build(("session_id", uuid.to_string())) + .expires(None) + .secure(true) + .http_only(true) + .path("/") + .build(), ) .remove("authorization_endpoint") .remove("me") .remove("iss") .remove("code_verifier"); - (StatusCode::FOUND, [(LOCATION, HeaderValue::from_static("/")), (CACHE_CONTROL, HeaderValue::from_static("no-store"))], dbg!(cookies)).into_response() + ( + StatusCode::FOUND, + [ + (LOCATION, HeaderValue::from_static("/")), + (CACHE_CONTROL, HeaderValue::from_static("no-store")), + ], + dbg!(cookies), + ) + .into_response() } /// Show the form necessary for logout. If JS is enabled, @@ -288,32 +381,42 @@ async fn callback( /// stupid enough to execute JS and send a POST request though, that's /// on the crawler. async fn logout_page() -> impl axum::response::IntoResponse { - (StatusCode::OK, [("Content-Type", "text/html")], Template { - title: "Signing out...", - blog_name: "Kittybox", - feeds: vec![], - user: None, - content: LogoutPage {}.to_string() - }.to_string()) + ( + StatusCode::OK, + [("Content-Type", "text/html")], + Template { + title: "Signing out...", + blog_name: "Kittybox", + feeds: vec![], + user: None, + content: LogoutPage {}.to_string(), + } + .to_string(), + ) } /// Erase the necessary cookies for login and invalidate the session. async fn logout( mut cookies: SignedCookieJar, - State(session_store): State<crate::SessionStore> -) -> (StatusCode, [(&'static str, &'static str); 1], SignedCookieJar) { - if let Some(id) = cookies.get("session_id") + State(session_store): State<crate::SessionStore>, +) -> ( + StatusCode, + [(&'static str, &'static str); 1], + SignedCookieJar, +) { + if let Some(id) = cookies + .get("session_id") .and_then(|c| uuid::Uuid::parse_str(c.value_trimmed()).ok()) { session_store.write().await.remove(&id); } - cookies = cookies.remove("me") + cookies = cookies + .remove("me") .remove("iss") .remove("authorization_endpoint") .remove("code_verifier") .remove("session_id"); - (StatusCode::FOUND, [("Location", "/")], cookies) } @@ -343,7 +446,7 @@ async fn client_metadata<S: Storage + Send + Sync + 'static>( }; if let Some(cached) = cached { if cached.precondition_passes(&etag) { - return StatusCode::NOT_MODIFIED.into_response() + return StatusCode::NOT_MODIFIED.into_response(); } } let client_uri: url::Url = format!("https://{}/", host).parse().unwrap(); @@ -356,7 +459,13 @@ async fn client_metadata<S: Storage + Send + Sync + 'static>( let mut metadata = kittybox_indieauth::ClientMetadata::new(client_id, client_uri).unwrap(); - metadata.client_name = Some(storage.get_setting::<crate::database::settings::SiteName>(&metadata.client_uri).await.unwrap_or_default().0); + metadata.client_name = Some( + storage + .get_setting::<crate::database::settings::SiteName>(&metadata.client_uri) + .await + .unwrap_or_default() + .0, + ); metadata.grant_types = Some(vec![GrantType::AuthorizationCode]); // We don't request anything more than the profile scope. metadata.scope = Some(Scopes::new(vec![Scope::Profile])); @@ -368,15 +477,18 @@ async fn client_metadata<S: Storage + Send + Sync + 'static>( // identity providers, or json to match newest spec let mut response = metadata.into_response(); // Indicate to upstream caches this endpoint does different things depending on the Accept: header. - response.headers_mut().append("Vary", HeaderValue::from_static("Accept")); + response + .headers_mut() + .append("Vary", HeaderValue::from_static("Accept")); // Cache this metadata for an hour. - response.headers_mut().append("Cache-Control", HeaderValue::from_static("max-age=600")); + response + .headers_mut() + .append("Cache-Control", HeaderValue::from_static("max-age=600")); response.headers_mut().typed_insert(etag); response } - /// Produce a router for all of the above. pub fn router<St, S>() -> axum::routing::Router<St> where diff --git a/src/main.rs b/src/main.rs index bd3684e..984745a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ -use kittybox::{database::Storage, indieauth::backend::AuthBackend, media::storage::MediaStore, webmentions::Webmention, compose_kittybox}; -use tokio::{sync::Mutex, task::JoinSet}; +use kittybox::{ + compose_kittybox, database::Storage, indieauth::backend::AuthBackend, + media::storage::MediaStore, webmentions::Webmention, +}; use std::{env, future::IntoFuture, sync::Arc}; +use tokio::{sync::Mutex, task::JoinSet}; use tracing::error; - #[tokio::main] async fn main() { use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry}; @@ -17,32 +19,28 @@ async fn main() { .with_indent_lines(true) .with_verbose_exit(true), #[cfg(not(debug_assertions))] - tracing_subscriber::fmt::layer().json() - .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout().lock())) + tracing_subscriber::fmt::layer() + .json() + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout().lock())), ); // In debug builds, also log to JSON, but to file. #[cfg(debug_assertions)] - let tracing_registry = tracing_registry.with( - tracing_subscriber::fmt::layer() - .json() - .with_writer({ - let instant = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap(); - move || std::fs::OpenOptions::new() + let tracing_registry = + tracing_registry.with(tracing_subscriber::fmt::layer().json().with_writer({ + let instant = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + move || { + std::fs::OpenOptions::new() .append(true) .create(true) - .open( - format!( - "{}.log.json", - instant - .as_secs_f64() - .to_string() - .replace('.', "_") - ) - ).unwrap() - }) - ); + .open(format!( + "{}.log.json", + instant.as_secs_f64().to_string().replace('.', "_") + )) + .unwrap() + } + })); tracing_registry.init(); tracing::info!("Starting the kittybox server..."); @@ -79,12 +77,15 @@ async fn main() { }); // TODO: load from environment - let cookie_key = axum_extra::extract::cookie::Key::from(&env::var("COOKIE_KEY") - .as_deref() - .map(|s| data_encoding::BASE64_MIME_PERMISSIVE.decode(s.as_bytes()) - .expect("Invalid cookie key: must be base64 encoded") - ) - .unwrap() + let cookie_key = axum_extra::extract::cookie::Key::from( + &env::var("COOKIE_KEY") + .as_deref() + .map(|s| { + data_encoding::BASE64_MIME_PERMISSIVE + .decode(s.as_bytes()) + .expect("Invalid cookie key: must be base64 encoded") + }) + .unwrap(), ); let cancellation_token = tokio_util::sync::CancellationToken::new(); @@ -93,12 +94,11 @@ async fn main() { let http: reqwest_middleware::ClientWithMiddleware = { #[allow(unused_mut)] - let mut builder = reqwest::Client::builder() - .user_agent(concat!( - env!("CARGO_PKG_NAME"), - "/", - env!("CARGO_PKG_VERSION") - )); + let mut builder = reqwest::Client::builder().user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); if let Ok(certs) = std::env::var("KITTYBOX_CUSTOM_PKI_ROOTS") { // TODO: add a root certificate if there's an environment variable pointing at it for path in certs.split(':') { @@ -108,21 +108,19 @@ async fn main() { tracing::error!("TLS root certificate {} not found, skipping...", path); continue; } - Err(err) => panic!("Error loading TLS certificates: {}", err) + Err(err) => panic!("Error loading TLS certificates: {}", err), }; if metadata.is_dir() { let mut dir = tokio::fs::read_dir(path).await.unwrap(); while let Ok(Some(file)) = dir.next_entry().await { let pem = tokio::fs::read(file.path()).await.unwrap(); - builder = builder.add_root_certificate( - reqwest::Certificate::from_pem(&pem).unwrap() - ); + builder = builder + .add_root_certificate(reqwest::Certificate::from_pem(&pem).unwrap()); } } else { let pem = tokio::fs::read(path).await.unwrap(); - builder = builder.add_root_certificate( - reqwest::Certificate::from_pem(&pem).unwrap() - ); + builder = + builder.add_root_certificate(reqwest::Certificate::from_pem(&pem).unwrap()); } } } @@ -151,7 +149,7 @@ async fn main() { let job_queue_type = job_queue_uri.scheme(); macro_rules! compose_kittybox { - ($auth:ty, $store:ty, $media:ty, $queue:ty) => { { + ($auth:ty, $store:ty, $media:ty, $queue:ty) => {{ type AuthBackend = $auth; type Storage = $store; type MediaStore = $media; @@ -193,36 +191,43 @@ async fn main() { }; type St = kittybox::AppState<AuthBackend, Storage, MediaStore, JobQueue>; - let stateful_router = compose_kittybox::<St, AuthBackend, Storage, MediaStore, JobQueue>().await; - let task = kittybox::webmentions::supervised_webmentions_task::<St, Storage, JobQueue>(&state, cancellation_token.clone()); + let stateful_router = + compose_kittybox::<St, AuthBackend, Storage, MediaStore, JobQueue>().await; + let task = kittybox::webmentions::supervised_webmentions_task::<St, Storage, JobQueue>( + &state, + cancellation_token.clone(), + ); let router = stateful_router.with_state(state); (router, task) - } } + }}; } - let (router, webmentions_task): (axum::Router<()>, kittybox::webmentions::SupervisedTask) = match (authstore_type, backend_type, blobstore_type, job_queue_type) { - ("file", "file", "file", "postgres") => { - compose_kittybox!( - kittybox::indieauth::backend::fs::FileBackend, - kittybox::database::FileStorage, - kittybox::media::storage::file::FileStore, - kittybox::webmentions::queue::PostgresJobQueue<Webmention> - ) - }, - ("file", "postgres", "file", "postgres") => { - compose_kittybox!( - kittybox::indieauth::backend::fs::FileBackend, - kittybox::database::PostgresStorage, - kittybox::media::storage::file::FileStore, - kittybox::webmentions::queue::PostgresJobQueue<Webmention> - ) - }, - (_, _, _, _) => { - // TODO: refine this error. - panic!("Invalid type for AUTH_STORE_URI, BACKEND_URI, BLOBSTORE_URI or JOB_QUEUE_URI"); - } - }; + let (router, webmentions_task): (axum::Router<()>, kittybox::webmentions::SupervisedTask) = + match (authstore_type, backend_type, blobstore_type, job_queue_type) { + ("file", "file", "file", "postgres") => { + compose_kittybox!( + kittybox::indieauth::backend::fs::FileBackend, + kittybox::database::FileStorage, + kittybox::media::storage::file::FileStore, + kittybox::webmentions::queue::PostgresJobQueue<Webmention> + ) + } + ("file", "postgres", "file", "postgres") => { + compose_kittybox!( + kittybox::indieauth::backend::fs::FileBackend, + kittybox::database::PostgresStorage, + kittybox::media::storage::file::FileStore, + kittybox::webmentions::queue::PostgresJobQueue<Webmention> + ) + } + (_, _, _, _) => { + // TODO: refine this error. + panic!( + "Invalid type for AUTH_STORE_URI, BACKEND_URI, BLOBSTORE_URI or JOB_QUEUE_URI" + ); + } + }; let mut servers: Vec<axum::serve::Serve<_, _, _>> = vec![]; @@ -238,7 +243,7 @@ async fn main() { // .serve(router.clone().into_make_service()) axum::serve( tokio::net::TcpListener::from_std(tcp).unwrap(), - router.clone() + router.clone(), ) }; @@ -246,8 +251,8 @@ async fn main() { for i in 0..(listenfd.len()) { match listenfd.take_tcp_listener(i) { Ok(Some(tcp)) => servers.push(build_hyper(tcp)), - Ok(None) => {}, - Err(err) => tracing::error!("Error binding to socket in fd {}: {}", i, err) + Ok(None) => {} + Err(err) => tracing::error!("Error binding to socket in fd {}: {}", i, err), } } // TODO this requires the `hyperlocal` crate @@ -302,24 +307,35 @@ async fn main() { // to get rid of an extra reference to `jobset` drop(router); // Polling streams mutates them - let mut servers_futures = Box::pin(servers.into_iter() - .map( - #[cfg(not(tokio_unstable))] |server| tokio::task::spawn( - server.with_graceful_shutdown(cancellation_token.clone().cancelled_owned()) - .into_future() - ), - #[cfg(tokio_unstable)] |server| { - tokio::task::Builder::new() - .name(format!("Kittybox HTTP acceptor: {:?}", server).as_str()) - .spawn( - server.with_graceful_shutdown( - cancellation_token.clone().cancelled_owned() - ).into_future() + let mut servers_futures = Box::pin( + servers + .into_iter() + .map( + #[cfg(not(tokio_unstable))] + |server| { + tokio::task::spawn( + server + .with_graceful_shutdown(cancellation_token.clone().cancelled_owned()) + .into_future(), ) - .unwrap() - } - ) - .collect::<futures_util::stream::FuturesUnordered<tokio::task::JoinHandle<Result<(), std::io::Error>>>>() + }, + #[cfg(tokio_unstable)] + |server| { + tokio::task::Builder::new() + .name(format!("Kittybox HTTP acceptor: {:?}", server).as_str()) + .spawn( + server + .with_graceful_shutdown( + cancellation_token.clone().cancelled_owned(), + ) + .into_future(), + ) + .unwrap() + }, + ) + .collect::<futures_util::stream::FuturesUnordered< + tokio::task::JoinHandle<Result<(), std::io::Error>>, + >>(), ); #[cfg(not(unix))] @@ -329,10 +345,10 @@ async fn main() { use tokio::signal::unix::{signal, SignalKind}; async move { - let mut interrupt = signal(SignalKind::interrupt()) - .expect("Failed to set up SIGINT handler"); - let mut terminate = signal(SignalKind::terminate()) - .expect("Failed to setup SIGTERM handler"); + let mut interrupt = + signal(SignalKind::interrupt()).expect("Failed to set up SIGINT handler"); + let mut terminate = + signal(SignalKind::terminate()).expect("Failed to setup SIGTERM handler"); tokio::select! { _ = terminate.recv() => {}, diff --git a/src/media/mod.rs b/src/media/mod.rs index 6f263b6..7e52414 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -1,22 +1,23 @@ +use crate::indieauth::{backend::AuthBackend, User}; use axum::{ - extract::{multipart::Multipart, FromRef, Path, State}, response::{IntoResponse, Response} + extract::{multipart::Multipart, FromRef, Path, State}, + response::{IntoResponse, Response}, }; -use axum_extra::headers::{ContentLength, HeaderMapExt, HeaderValue, IfNoneMatch}; use axum_extra::extract::Host; +use axum_extra::headers::{ContentLength, HeaderMapExt, HeaderValue, IfNoneMatch}; use axum_extra::TypedHeader; -use kittybox_util::micropub::{Error as MicropubError, ErrorKind as ErrorType}; use kittybox_indieauth::Scope; -use crate::indieauth::{backend::AuthBackend, User}; +use kittybox_util::micropub::{Error as MicropubError, ErrorKind as ErrorType}; pub mod storage; -use storage::{MediaStore, MediaStoreError, Metadata, ErrorKind}; pub use storage::file::FileStore; +use storage::{ErrorKind, MediaStore, MediaStoreError, Metadata}; impl From<MediaStoreError> for MicropubError { fn from(err: MediaStoreError) -> Self { Self::new( ErrorType::InternalServerError, - format!("media store error: {}", err) + format!("media store error: {}", err), ) } } @@ -25,13 +26,14 @@ impl From<MediaStoreError> for MicropubError { pub(crate) async fn upload<S: MediaStore, A: AuthBackend>( State(blobstore): State<S>, user: User<A>, - mut upload: Multipart + mut upload: Multipart, ) -> Response { if !user.check_scope(&Scope::Media) { return MicropubError::from_static( ErrorType::NotAuthorized, - "Interacting with the media storage requires the \"media\" scope." - ).into_response(); + "Interacting with the media storage requires the \"media\" scope.", + ) + .into_response(); } let host = user.me.authority(); let field = match upload.next_field().await { @@ -39,27 +41,31 @@ pub(crate) async fn upload<S: MediaStore, A: AuthBackend>( Ok(None) => { return MicropubError::from_static( ErrorType::InvalidRequest, - "Send multipart/form-data with one field named file" - ).into_response(); - }, + "Send multipart/form-data with one field named file", + ) + .into_response(); + } Err(err) => { return MicropubError::new( ErrorType::InternalServerError, - format!("Error while parsing multipart/form-data: {}", err) - ).into_response(); - }, + format!("Error while parsing multipart/form-data: {}", err), + ) + .into_response(); + } }; let metadata: Metadata = (&field).into(); match blobstore.write_streaming(host, metadata, field).await { Ok(filename) => IntoResponse::into_response(( axum::http::StatusCode::CREATED, - [ - ("Location", user.me.join( - &format!(".kittybox/media/uploads/{}", filename) - ).unwrap().as_str()) - ] + [( + "Location", + user.me + .join(&format!(".kittybox/media/uploads/{}", filename)) + .unwrap() + .as_str(), + )], )), - Err(err) => MicropubError::from(err).into_response() + Err(err) => MicropubError::from(err).into_response(), } } @@ -68,7 +74,7 @@ pub(crate) async fn serve<S: MediaStore>( Host(host): Host, Path(path): Path<String>, if_none_match: Option<TypedHeader<IfNoneMatch>>, - State(blobstore): State<S> + State(blobstore): State<S>, ) -> Response { use axum::http::StatusCode; tracing::debug!("Searching for file..."); @@ -77,7 +83,9 @@ pub(crate) async fn serve<S: MediaStore>( tracing::debug!("Metadata: {:?}", metadata); let etag = if let Some(etag) = metadata.etag { - let etag = format!("\"{}\"", etag).parse::<axum_extra::headers::ETag>().unwrap(); + let etag = format!("\"{}\"", etag) + .parse::<axum_extra::headers::ETag>() + .unwrap(); if let Some(TypedHeader(if_none_match)) = if_none_match { tracing::debug!("If-None-Match: {:?}", if_none_match); @@ -85,12 +93,14 @@ pub(crate) async fn serve<S: MediaStore>( // returns 304 when it doesn't match because it // only matches when file is different if !if_none_match.precondition_passes(&etag) { - return StatusCode::NOT_MODIFIED.into_response() + return StatusCode::NOT_MODIFIED.into_response(); } } Some(etag) - } else { None }; + } else { + None + }; let mut r = Response::builder(); { @@ -98,14 +108,16 @@ pub(crate) async fn serve<S: MediaStore>( headers.insert( "Content-Type", HeaderValue::from_str( - metadata.content_type + metadata + .content_type .as_deref() - .unwrap_or("application/octet-stream") - ).unwrap() + .unwrap_or("application/octet-stream"), + ) + .unwrap(), ); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, - axum::http::HeaderValue::from_static("nosniff") + axum::http::HeaderValue::from_static("nosniff"), ); if let Some(length) = metadata.length { headers.typed_insert(ContentLength(length.get().try_into().unwrap())); @@ -117,23 +129,22 @@ pub(crate) async fn serve<S: MediaStore>( r.body(axum::body::Body::from_stream(stream)) .unwrap() .into_response() - }, + } Err(err) => match err.kind() { - ErrorKind::NotFound => { - IntoResponse::into_response(StatusCode::NOT_FOUND) - }, + ErrorKind::NotFound => IntoResponse::into_response(StatusCode::NOT_FOUND), _ => { tracing::error!("{}", err); IntoResponse::into_response(StatusCode::INTERNAL_SERVER_ERROR) } - } + }, } } -pub fn router<St, A, M>() -> axum::Router<St> where +pub fn router<St, A, M>() -> axum::Router<St> +where A: AuthBackend + FromRef<St>, M: MediaStore + FromRef<St>, - St: Clone + Send + Sync + 'static + St: Clone + Send + Sync + 'static, { axum::Router::new() .route("/", axum::routing::post(upload::<M, A>)) diff --git a/src/media/storage/file.rs b/src/media/storage/file.rs index e432945..5198a4c 100644 --- a/src/media/storage/file.rs +++ b/src/media/storage/file.rs @@ -1,12 +1,12 @@ -use super::{Metadata, ErrorKind, MediaStore, MediaStoreError, Result}; -use std::{path::PathBuf, fmt::Debug}; -use tokio::fs::OpenOptions; -use tokio::io::{BufReader, BufWriter, AsyncWriteExt, AsyncSeekExt}; +use super::{ErrorKind, MediaStore, MediaStoreError, Metadata, Result}; +use futures::FutureExt; use futures::{StreamExt, TryStreamExt}; +use sha2::Digest; use std::ops::{Bound, Neg}; use std::pin::Pin; -use sha2::Digest; -use futures::FutureExt; +use std::{fmt::Debug, path::PathBuf}; +use tokio::fs::OpenOptions; +use tokio::io::{AsyncSeekExt, AsyncWriteExt, BufReader, BufWriter}; use tracing::{debug, error}; const BUF_CAPACITY: usize = 16 * 1024; @@ -22,7 +22,7 @@ impl From<tokio::io::Error> for MediaStoreError { msg: format!("file I/O error: {}", source), kind: match source.kind() { std::io::ErrorKind::NotFound => ErrorKind::NotFound, - _ => ErrorKind::Backend + _ => ErrorKind::Backend, }, source: Some(Box::new(source)), } @@ -40,7 +40,9 @@ impl FileStore { impl MediaStore for FileStore { async fn new(url: &'_ url::Url) -> Result<Self> { - Ok(Self { base: url.path().into() }) + Ok(Self { + base: url.path().into(), + }) } #[tracing::instrument(skip(self, content))] @@ -51,10 +53,17 @@ impl MediaStore for FileStore { mut content: T, ) -> Result<String> where - T: tokio_stream::Stream<Item = std::result::Result<bytes::Bytes, axum::extract::multipart::MultipartError>> + Unpin + Send + Debug + T: tokio_stream::Stream< + Item = std::result::Result<bytes::Bytes, axum::extract::multipart::MultipartError>, + > + Unpin + + Send + + Debug, { let (tempfilepath, mut tempfile) = self.mktemp().await?; - debug!("Temporary file opened for storing pending upload: {}", tempfilepath.display()); + debug!( + "Temporary file opened for storing pending upload: {}", + tempfilepath.display() + ); let mut hasher = sha2::Sha256::new(); let mut length: usize = 0; @@ -62,7 +71,7 @@ impl MediaStore for FileStore { let chunk = chunk.map_err(|err| MediaStoreError { kind: ErrorKind::Backend, source: Some(Box::new(err)), - msg: "Failed to read a data chunk".to_owned() + msg: "Failed to read a data chunk".to_owned(), })?; debug!("Read {} bytes from the stream", chunk.len()); length += chunk.len(); @@ -70,9 +79,7 @@ impl MediaStore for FileStore { { let chunk = chunk.clone(); let tempfile = &mut tempfile; - async move { - tempfile.write_all(&chunk).await - } + async move { tempfile.write_all(&chunk).await } }, { let chunk = chunk.clone(); @@ -80,7 +87,8 @@ impl MediaStore for FileStore { hasher.update(&*chunk); hasher - }).map(|r| r.unwrap()) + }) + .map(|r| r.unwrap()) } ); if let Err(err) = write_result { @@ -90,7 +98,9 @@ impl MediaStore for FileStore { // though temporary files might take up space on the hard drive // We'll clean them when maintenance time comes #[allow(unused_must_use)] - { tokio::fs::remove_file(tempfilepath).await; } + { + tokio::fs::remove_file(tempfilepath).await; + } return Err(err.into()); } hasher = _hasher; @@ -113,10 +123,17 @@ impl MediaStore for FileStore { let filepath = self.base.join(domain_str.as_str()).join(&filename); let metafilename = filename.clone() + ".json"; let metapath = self.base.join(domain_str.as_str()).join(&metafilename); - let metatemppath = self.base.join(domain_str.as_str()).join(metafilename + ".tmp"); + let metatemppath = self + .base + .join(domain_str.as_str()) + .join(metafilename + ".tmp"); metadata.length = std::num::NonZeroUsize::new(length); metadata.etag = Some(hash); - debug!("File path: {}, metadata: {}", filepath.display(), metapath.display()); + debug!( + "File path: {}, metadata: {}", + filepath.display(), + metapath.display() + ); { let parent = filepath.parent().unwrap(); tokio::fs::create_dir_all(parent).await?; @@ -126,39 +143,44 @@ impl MediaStore for FileStore { .write(true) .open(&metatemppath) .await?; - meta.write_all(&serde_json::to_vec(&metadata).unwrap()).await?; + meta.write_all(&serde_json::to_vec(&metadata).unwrap()) + .await?; tokio::fs::rename(tempfilepath, filepath).await?; tokio::fs::rename(metatemppath, metapath).await?; Ok(filename) } #[tracing::instrument(skip(self))] + #[allow(clippy::type_complexity)] async fn read_streaming( &self, domain: &str, filename: &str, - ) -> Result<(Metadata, Pin<Box<dyn tokio_stream::Stream<Item = std::io::Result<bytes::Bytes>> + Send>>)> { + ) -> Result<( + Metadata, + Pin<Box<dyn tokio_stream::Stream<Item = std::io::Result<bytes::Bytes>> + Send>>, + )> { debug!("Domain: {}, filename: {}", domain, filename); let path = self.base.join(domain).join(filename); debug!("Path: {}", path.display()); - let file = OpenOptions::new() - .read(true) - .open(path) - .await?; + let file = OpenOptions::new().read(true).open(path).await?; let meta = self.metadata(domain, filename).await?; - Ok((meta, Box::pin( - tokio_util::io::ReaderStream::new( - // TODO: determine if BufReader provides benefit here - // From the logs it looks like we're reading 4KiB at a time - // Buffering file contents seems to double download speed - // How to benchmark this? - BufReader::with_capacity(BUF_CAPACITY, file) - ) - // Sprinkle some salt in form of protective log wrapping - .inspect_ok(|chunk| debug!("Read {} bytes from file", chunk.len())) - ))) + Ok(( + meta, + Box::pin( + tokio_util::io::ReaderStream::new( + // TODO: determine if BufReader provides benefit here + // From the logs it looks like we're reading 4KiB at a time + // Buffering file contents seems to double download speed + // How to benchmark this? + BufReader::with_capacity(BUF_CAPACITY, file), + ) + // Sprinkle some salt in form of protective log wrapping + .inspect_ok(|chunk| debug!("Read {} bytes from file", chunk.len())), + ), + )) } #[tracing::instrument(skip(self))] @@ -166,12 +188,13 @@ impl MediaStore for FileStore { let metapath = self.base.join(domain).join(format!("{}.json", filename)); debug!("Metadata path: {}", metapath.display()); - let meta = serde_json::from_slice(&tokio::fs::read(metapath).await?) - .map_err(|err| MediaStoreError { + let meta = serde_json::from_slice(&tokio::fs::read(metapath).await?).map_err(|err| { + MediaStoreError { kind: ErrorKind::Json, msg: format!("{}", err), - source: Some(Box::new(err)) - })?; + source: Some(Box::new(err)), + } + })?; Ok(meta) } @@ -181,16 +204,14 @@ impl MediaStore for FileStore { &self, domain: &str, filename: &str, - range: (Bound<u64>, Bound<u64>) - ) -> Result<Pin<Box<dyn tokio_stream::Stream<Item = std::io::Result<bytes::Bytes>> + Send>>> { + range: (Bound<u64>, Bound<u64>), + ) -> Result<Pin<Box<dyn tokio_stream::Stream<Item = std::io::Result<bytes::Bytes>> + Send>>> + { let path = self.base.join(format!("{}/{}", domain, filename)); let metapath = self.base.join(format!("{}/{}.json", domain, filename)); debug!("Path: {}, metadata: {}", path.display(), metapath.display()); - let mut file = OpenOptions::new() - .read(true) - .open(path) - .await?; + let mut file = OpenOptions::new().read(true).open(path).await?; let start = match range { (Bound::Included(bound), _) => { @@ -201,45 +222,52 @@ impl MediaStore for FileStore { (Bound::Unbounded, Bound::Included(bound)) => { // Seek to the end minus the bounded bytes debug!("Seeking {} bytes back from the end...", bound); - file.seek(std::io::SeekFrom::End(i64::try_from(bound).unwrap().neg())).await? - }, + file.seek(std::io::SeekFrom::End(i64::try_from(bound).unwrap().neg())) + .await? + } (Bound::Unbounded, Bound::Unbounded) => 0, - (_, Bound::Excluded(_)) => unreachable!() + (_, Bound::Excluded(_)) => unreachable!(), }; - let stream = Box::pin(tokio_util::io::ReaderStream::new(BufReader::with_capacity(BUF_CAPACITY, file))) - .map_ok({ - let mut bytes_read = 0usize; - let len = match range { - (_, Bound::Unbounded) => None, - (Bound::Unbounded, Bound::Included(bound)) => Some(bound), - (_, Bound::Included(bound)) => Some(bound + 1 - start), - (_, Bound::Excluded(_)) => unreachable!() - }; - move |chunk| { - debug!("Read {} bytes from file, {} in this chunk", bytes_read, chunk.len()); - bytes_read += chunk.len(); - if let Some(len) = len.map(|len| len.try_into().unwrap()) { - if bytes_read > len { - if bytes_read - len > chunk.len() { - return None - } - debug!("Truncating last {} bytes", bytes_read - len); - return Some(chunk.slice(..chunk.len() - (bytes_read - len))) + let stream = Box::pin(tokio_util::io::ReaderStream::new(BufReader::with_capacity( + BUF_CAPACITY, + file, + ))) + .map_ok({ + let mut bytes_read = 0usize; + let len = match range { + (_, Bound::Unbounded) => None, + (Bound::Unbounded, Bound::Included(bound)) => Some(bound), + (_, Bound::Included(bound)) => Some(bound + 1 - start), + (_, Bound::Excluded(_)) => unreachable!(), + }; + move |chunk| { + debug!( + "Read {} bytes from file, {} in this chunk", + bytes_read, + chunk.len() + ); + bytes_read += chunk.len(); + if let Some(len) = len.map(|len| len.try_into().unwrap()) { + if bytes_read > len { + if bytes_read - len > chunk.len() { + return None; } + debug!("Truncating last {} bytes", bytes_read - len); + return Some(chunk.slice(..chunk.len() - (bytes_read - len))); } - - Some(chunk) } - }) - .try_take_while(|x| std::future::ready(Ok(x.is_some()))) - // Will never panic, because the moment the stream yields - // a None, it is considered exhausted. - .map_ok(|x| x.unwrap()); - return Ok(Box::pin(stream)) - } + Some(chunk) + } + }) + .try_take_while(|x| std::future::ready(Ok(x.is_some()))) + // Will never panic, because the moment the stream yields + // a None, it is considered exhausted. + .map_ok(|x| x.unwrap()); + return Ok(Box::pin(stream)); + } async fn delete(&self, domain: &str, filename: &str) -> Result<()> { let path = self.base.join(format!("{}/{}", domain, filename)); @@ -250,7 +278,7 @@ impl MediaStore for FileStore { #[cfg(test)] mod tests { - use super::{Metadata, FileStore, MediaStore}; + use super::{FileStore, MediaStore, Metadata}; use std::ops::Bound; use tokio::io::AsyncReadExt; @@ -258,10 +286,15 @@ mod tests { #[tracing_test::traced_test] async fn test_ranges() { let tempdir = tempfile::tempdir().expect("Failed to create tempdir"); - let store = FileStore { base: tempdir.path().to_path_buf() }; + let store = FileStore { + base: tempdir.path().to_path_buf(), + }; let file: &[u8] = include_bytes!("./file.rs"); - let stream = tokio_stream::iter(file.chunks(100).map(|i| Ok(bytes::Bytes::copy_from_slice(i)))); + let stream = tokio_stream::iter( + file.chunks(100) + .map(|i| Ok(bytes::Bytes::copy_from_slice(i))), + ); let metadata = Metadata { filename: Some("file.rs".to_string()), content_type: Some("text/plain".to_string()), @@ -270,28 +303,30 @@ mod tests { }; // write through the interface - let filename = store.write_streaming( - "fireburn.ru", - metadata, stream - ).await.unwrap(); + let filename = store + .write_streaming("fireburn.ru", metadata, stream) + .await + .unwrap(); tracing::debug!("Writing complete."); // Ensure the file is there - let content = tokio::fs::read( - tempdir.path() - .join("fireburn.ru") - .join(&filename) - ).await.unwrap(); + let content = tokio::fs::read(tempdir.path().join("fireburn.ru").join(&filename)) + .await + .unwrap(); assert_eq!(content, file); tracing::debug!("Reading range from the start..."); // try to read range let range = { - let stream = store.stream_range( - "fireburn.ru", &filename, - (Bound::Included(0), Bound::Included(299)) - ).await.unwrap(); + let stream = store + .stream_range( + "fireburn.ru", + &filename, + (Bound::Included(0), Bound::Included(299)), + ) + .await + .unwrap(); let mut reader = tokio_util::io::StreamReader::new(stream); @@ -307,10 +342,14 @@ mod tests { tracing::debug!("Reading range from the middle..."); let range = { - let stream = store.stream_range( - "fireburn.ru", &filename, - (Bound::Included(150), Bound::Included(449)) - ).await.unwrap(); + let stream = store + .stream_range( + "fireburn.ru", + &filename, + (Bound::Included(150), Bound::Included(449)), + ) + .await + .unwrap(); let mut reader = tokio_util::io::StreamReader::new(stream); @@ -325,13 +364,17 @@ mod tests { tracing::debug!("Reading range from the end..."); let range = { - let stream = store.stream_range( - "fireburn.ru", &filename, - // Note: the `headers` crate parses bounds in a - // non-standard way, where unbounded start actually - // means getting things from the end... - (Bound::Unbounded, Bound::Included(300)) - ).await.unwrap(); + let stream = store + .stream_range( + "fireburn.ru", + &filename, + // Note: the `headers` crate parses bounds in a + // non-standard way, where unbounded start actually + // means getting things from the end... + (Bound::Unbounded, Bound::Included(300)), + ) + .await + .unwrap(); let mut reader = tokio_util::io::StreamReader::new(stream); @@ -342,15 +385,19 @@ mod tests { }; assert_eq!(range.len(), 300); - assert_eq!(range.as_slice(), &file[file.len()-300..file.len()]); + assert_eq!(range.as_slice(), &file[file.len() - 300..file.len()]); tracing::debug!("Reading the whole file..."); // try to read range let range = { - let stream = store.stream_range( - "fireburn.ru", &("/".to_string() + &filename), - (Bound::Unbounded, Bound::Unbounded) - ).await.unwrap(); + let stream = store + .stream_range( + "fireburn.ru", + &("/".to_string() + &filename), + (Bound::Unbounded, Bound::Unbounded), + ) + .await + .unwrap(); let mut reader = tokio_util::io::StreamReader::new(stream); @@ -364,15 +411,19 @@ mod tests { assert_eq!(range.as_slice(), file); } - #[tokio::test] #[tracing_test::traced_test] async fn test_streaming_read_write() { let tempdir = tempfile::tempdir().expect("Failed to create tempdir"); - let store = FileStore { base: tempdir.path().to_path_buf() }; + let store = FileStore { + base: tempdir.path().to_path_buf(), + }; let file: &[u8] = include_bytes!("./file.rs"); - let stream = tokio_stream::iter(file.chunks(100).map(|i| Ok(bytes::Bytes::copy_from_slice(i)))); + let stream = tokio_stream::iter( + file.chunks(100) + .map(|i| Ok(bytes::Bytes::copy_from_slice(i))), + ); let metadata = Metadata { filename: Some("style.css".to_string()), content_type: Some("text/css".to_string()), @@ -381,27 +432,32 @@ mod tests { }; // write through the interface - let filename = store.write_streaming( - "fireburn.ru", - metadata, stream - ).await.unwrap(); - println!("{}, {}", filename, tempdir.path() - .join("fireburn.ru") - .join(&filename) - .display()); - let content = tokio::fs::read( - tempdir.path() - .join("fireburn.ru") - .join(&filename) - ).await.unwrap(); + let filename = store + .write_streaming("fireburn.ru", metadata, stream) + .await + .unwrap(); + println!( + "{}, {}", + filename, + tempdir.path().join("fireburn.ru").join(&filename).display() + ); + let content = tokio::fs::read(tempdir.path().join("fireburn.ru").join(&filename)) + .await + .unwrap(); assert_eq!(content, file); // check internal metadata format - let meta: Metadata = serde_json::from_slice(&tokio::fs::read( - tempdir.path() - .join("fireburn.ru") - .join(filename.clone() + ".json") - ).await.unwrap()).unwrap(); + let meta: Metadata = serde_json::from_slice( + &tokio::fs::read( + tempdir + .path() + .join("fireburn.ru") + .join(filename.clone() + ".json"), + ) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(meta.content_type.as_deref(), Some("text/css")); assert_eq!(meta.filename.as_deref(), Some("style.css")); assert_eq!(meta.length.map(|i| i.get()), Some(file.len())); @@ -409,10 +465,10 @@ mod tests { // read back the data using the interface let (metadata, read_back) = { - let (metadata, stream) = store.read_streaming( - "fireburn.ru", - &filename - ).await.unwrap(); + let (metadata, stream) = store + .read_streaming("fireburn.ru", &filename) + .await + .unwrap(); let mut reader = tokio_util::io::StreamReader::new(stream); let mut buf = Vec::default(); @@ -426,6 +482,5 @@ mod tests { assert_eq!(meta.filename.as_deref(), Some("style.css")); assert_eq!(meta.length.map(|i| i.get()), Some(file.len())); assert!(meta.etag.is_some()); - } } diff --git a/src/media/storage/mod.rs b/src/media/storage/mod.rs index 551b61e..5658071 100644 --- a/src/media/storage/mod.rs +++ b/src/media/storage/mod.rs @@ -1,12 +1,12 @@ use axum::extract::multipart::Field; -use tokio_stream::Stream; use bytes::Bytes; use serde::{Deserialize, Serialize}; +use std::fmt::Debug; use std::future::Future; +use std::num::NonZeroUsize; use std::ops::Bound; use std::pin::Pin; -use std::fmt::Debug; -use std::num::NonZeroUsize; +use tokio_stream::Stream; pub mod file; @@ -24,17 +24,14 @@ pub struct Metadata { impl From<&Field<'_>> for Metadata { fn from(field: &Field<'_>) -> Self { Self { - content_type: field.content_type() - .map(|i| i.to_owned()), - filename: field.file_name() - .map(|i| i.to_owned()), + content_type: field.content_type().map(|i| i.to_owned()), + filename: field.file_name().map(|i| i.to_owned()), length: None, etag: None, } } } - #[derive(Debug, Clone, Copy)] pub enum ErrorKind { Backend, @@ -95,87 +92,116 @@ pub trait MediaStore: 'static + Send + Sync + Clone { content: T, ) -> impl Future<Output = Result<String>> + Send where - T: tokio_stream::Stream<Item = std::result::Result<bytes::Bytes, axum::extract::multipart::MultipartError>> + Unpin + Send + Debug; + T: tokio_stream::Stream< + Item = std::result::Result<bytes::Bytes, axum::extract::multipart::MultipartError>, + > + Unpin + + Send + + Debug; + #[allow(clippy::type_complexity)] fn read_streaming( &self, domain: &str, filename: &str, - ) -> impl Future<Output = Result< - (Metadata, Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send>>) - >> + Send; + ) -> impl Future< + Output = Result<( + Metadata, + Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send>>, + )>, + > + Send; fn stream_range( &self, domain: &str, filename: &str, - range: (Bound<u64>, Bound<u64>) - ) -> impl Future<Output = Result<Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send>>>> + Send { async move { - use futures::stream::TryStreamExt; - use tracing::debug; - let (metadata, mut stream) = self.read_streaming(domain, filename).await?; - let length = metadata.length.unwrap().get(); - - use Bound::*; - let (start, end): (usize, usize) = match range { - (Unbounded, Unbounded) => return Ok(stream), - (Included(start), Unbounded) => (start.try_into().unwrap(), length - 1), - (Unbounded, Included(end)) => (length - usize::try_from(end).unwrap(), length - 1), - (Included(start), Included(end)) => (start.try_into().unwrap(), end.try_into().unwrap()), - (_, _) => unreachable!() - }; - - stream = Box::pin( - stream.map_ok({ - let mut bytes_skipped = 0usize; - let mut bytes_read = 0usize; - - move |chunk| { - debug!("Skipped {}/{} bytes, chunk len {}", bytes_skipped, start, chunk.len()); - let chunk = if bytes_skipped < start { - let need_to_skip = start - bytes_skipped; - if chunk.len() < need_to_skip { - return None - } - debug!("Skipping {} bytes", need_to_skip); - bytes_skipped += need_to_skip; - - chunk.slice(need_to_skip..) - } else { - chunk - }; - - debug!("Read {} bytes from file, {} in this chunk", bytes_read, chunk.len()); - bytes_read += chunk.len(); - - if bytes_read > length { - if bytes_read - length > chunk.len() { - return None - } - debug!("Truncating last {} bytes", bytes_read - length); - return Some(chunk.slice(..chunk.len() - (bytes_read - length))) - } - - Some(chunk) + range: (Bound<u64>, Bound<u64>), + ) -> impl Future<Output = Result<Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send>>>> + Send + { + async move { + use futures::stream::TryStreamExt; + use tracing::debug; + let (metadata, mut stream) = self.read_streaming(domain, filename).await?; + let length = metadata.length.unwrap().get(); + + use Bound::*; + let (start, end): (usize, usize) = match range { + (Unbounded, Unbounded) => return Ok(stream), + (Included(start), Unbounded) => (start.try_into().unwrap(), length - 1), + (Unbounded, Included(end)) => (length - usize::try_from(end).unwrap(), length - 1), + (Included(start), Included(end)) => { + (start.try_into().unwrap(), end.try_into().unwrap()) } - }) - .try_skip_while(|x| std::future::ready(Ok(x.is_none()))) - .try_take_while(|x| std::future::ready(Ok(x.is_some()))) - .map_ok(|x| x.unwrap()) - ); + (_, _) => unreachable!(), + }; + + stream = Box::pin( + stream + .map_ok({ + let mut bytes_skipped = 0usize; + let mut bytes_read = 0usize; + + move |chunk| { + debug!( + "Skipped {}/{} bytes, chunk len {}", + bytes_skipped, + start, + chunk.len() + ); + let chunk = if bytes_skipped < start { + let need_to_skip = start - bytes_skipped; + if chunk.len() < need_to_skip { + return None; + } + debug!("Skipping {} bytes", need_to_skip); + bytes_skipped += need_to_skip; + + chunk.slice(need_to_skip..) + } else { + chunk + }; + + debug!( + "Read {} bytes from file, {} in this chunk", + bytes_read, + chunk.len() + ); + bytes_read += chunk.len(); + + if bytes_read > length { + if bytes_read - length > chunk.len() { + return None; + } + debug!("Truncating last {} bytes", bytes_read - length); + return Some(chunk.slice(..chunk.len() - (bytes_read - length))); + } + + Some(chunk) + } + }) + .try_skip_while(|x| std::future::ready(Ok(x.is_none()))) + .try_take_while(|x| std::future::ready(Ok(x.is_some()))) + .map_ok(|x| x.unwrap()), + ); - Ok(stream) - } } + Ok(stream) + } + } /// Read metadata for a file. /// /// The default implementation uses the `read_streaming` method /// and drops the stream containing file content. - fn metadata(&self, domain: &str, filename: &str) -> impl Future<Output = Result<Metadata>> + Send { async move { - self.read_streaming(domain, filename) - .await - .map(|(meta, _)| meta) - } } + fn metadata( + &self, + domain: &str, + filename: &str, + ) -> impl Future<Output = Result<Metadata>> + Send { + async move { + self.read_streaming(domain, filename) + .await + .map(|(meta, _)| meta) + } + } fn delete(&self, domain: &str, filename: &str) -> impl Future<Output = Result<()>> + Send; } diff --git a/src/metrics.rs b/src/metrics.rs deleted file mode 100644 index e13fcb9..0000000 --- a/src/metrics.rs +++ /dev/null @@ -1,21 +0,0 @@ -#![allow(unused_imports, dead_code)] -use async_trait::async_trait; -use lazy_static::lazy_static; -use prometheus::Encoder; -use std::time::{Duration, Instant}; - -// TODO: Vendor in the Metrics struct from warp_prometheus and rework the path matching algorithm - -pub fn metrics(path_includes: Vec<String>) -> warp::log::Log<impl Fn(warp::log::Info) + Clone> { - let metrics = warp_prometheus::Metrics::new(prometheus::default_registry(), &path_includes); - warp::log::custom(move |info| metrics.http_metrics(info)) -} - -pub fn gather() -> Vec<u8> { - let mut buffer: Vec<u8> = vec![]; - let encoder = prometheus::TextEncoder::new(); - let metric_families = prometheus::gather(); - encoder.encode(&metric_families, &mut buffer).unwrap(); - - buffer -} diff --git a/src/micropub/mod.rs b/src/micropub/mod.rs index 719fbf0..5e11033 100644 --- a/src/micropub/mod.rs +++ b/src/micropub/mod.rs @@ -1,25 +1,26 @@ use std::collections::HashMap; -use url::Url; use std::sync::Arc; +use url::Url; +use util::NormalizedPost; use crate::database::{MicropubChannel, Storage, StorageError}; use crate::indieauth::backend::AuthBackend; use crate::indieauth::User; use crate::micropub::util::form_to_mf2_json; -use axum::extract::{FromRef, Query, State}; use axum::body::Body as BodyStream; +use axum::extract::{FromRef, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use axum_extra::extract::Host; use axum_extra::headers::ContentType; -use axum::response::{IntoResponse, Response}; use axum_extra::TypedHeader; -use axum::http::StatusCode; +use kittybox_indieauth::{Scope, TokenData}; +use kittybox_util::micropub::{Error as MicropubError, ErrorKind, QueryType}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; use tokio::task::JoinSet; use tracing::{debug, error, info, warn}; -use kittybox_indieauth::{Scope, TokenData}; -use kittybox_util::micropub::{Error as MicropubError, ErrorKind, QueryType}; #[derive(Serialize, Deserialize, Debug)] pub struct MicropubQuery { @@ -34,12 +35,12 @@ impl From<StorageError> for MicropubError { crate::database::ErrorKind::NotFound => ErrorKind::NotFound, _ => ErrorKind::InternalServerError, }, - format!("backend error: {}", err) + format!("backend error: {}", err), ) } } -mod util; +pub(crate) mod util; pub(crate) use util::normalize_mf2; #[derive(Debug)] @@ -58,7 +59,8 @@ fn populate_reply_context( array .iter() .map(|i| { - let mut item = i.as_str() + let mut item = i + .as_str() .and_then(|i| i.parse::<Url>().ok()) .and_then(|url| ctxs.get(&url)) .and_then(|ctx| ctx.mf2["items"].get(0)) @@ -68,7 +70,12 @@ fn populate_reply_context( if item.is_object() && (i != &item) { if let Some(props) = item["properties"].as_object_mut() { // Fixup the item: if it lacks a URL, add one. - if !props.get("url").and_then(serde_json::Value::as_array).map(|a| !a.is_empty()).unwrap_or(false) { + if !props + .get("url") + .and_then(serde_json::Value::as_array) + .map(|a| !a.is_empty()) + .unwrap_or(false) + { props.insert("url".to_owned(), json!([i.as_str()])); } } @@ -144,11 +151,14 @@ async fn background_processing<D: 'static + Storage>( .get("webmention") .and_then(|i| i.first().cloned()); - dbg!(Some((url.clone(), FetchedPostContext { - url, - mf2: serde_json::to_value(mf2).unwrap(), - webmention - }))) + dbg!(Some(( + url.clone(), + FetchedPostContext { + url, + mf2: serde_json::to_value(mf2).unwrap(), + webmention + } + ))) }) .collect::<HashMap<Url, FetchedPostContext>>() .await @@ -160,7 +170,11 @@ async fn background_processing<D: 'static + Storage>( }; for prop in context_props { if let Some(json) = populate_reply_context(&mf2, prop, &post_contexts) { - update.replace.as_mut().unwrap().insert(prop.to_owned(), json); + update + .replace + .as_mut() + .unwrap() + .insert(prop.to_owned(), json); } } if !update.replace.as_ref().unwrap().is_empty() { @@ -249,7 +263,7 @@ pub(crate) async fn _post<D: 'static + Storage>( if !user.check_scope(&Scope::Create) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "Not enough privileges - try acquiring the \"create\" scope." + "Not enough privileges - try acquiring the \"create\" scope.", )); } @@ -263,7 +277,7 @@ pub(crate) async fn _post<D: 'static + Storage>( { return Err(MicropubError::from_static( ErrorKind::Forbidden, - "You're posting to a website that's not yours." + "You're posting to a website that's not yours.", )); } @@ -271,7 +285,7 @@ pub(crate) async fn _post<D: 'static + Storage>( if db.post_exists(&uid).await? { return Err(MicropubError::from_static( ErrorKind::AlreadyExists, - "UID clash was detected, operation aborted." + "UID clash was detected, operation aborted.", )); } // Save the post @@ -308,13 +322,18 @@ pub(crate) async fn _post<D: 'static + Storage>( } } - let reply = - IntoResponse::into_response((StatusCode::ACCEPTED, [("Location", uid.as_str())])); + let reply = IntoResponse::into_response((StatusCode::ACCEPTED, [("Location", uid.as_str())])); #[cfg(not(tokio_unstable))] - let _ = jobset.lock().await.spawn(background_processing(db, mf2, http)); + let _ = jobset + .lock() + .await + .spawn(background_processing(db, mf2, http)); #[cfg(tokio_unstable)] - let _ = jobset.lock().await.build_task() + let _ = jobset + .lock() + .await + .build_task() .name(format!("Kittybox background processing for post {}", uid.as_str()).as_str()) .spawn(background_processing(db, mf2, http)); @@ -332,7 +351,7 @@ enum ActionType { #[serde(untagged)] pub enum MicropubPropertyDeletion { Properties(Vec<String>), - Values(HashMap<String, Vec<serde_json::Value>>) + Values(HashMap<String, Vec<serde_json::Value>>), } #[derive(Serialize, Deserialize)] struct MicropubFormAction { @@ -346,7 +365,7 @@ pub struct MicropubAction { url: String, #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] - update: Option<MicropubUpdate> + update: Option<MicropubUpdate>, } #[derive(Serialize, Deserialize, Debug, Default)] @@ -361,39 +380,43 @@ pub struct MicropubUpdate { impl MicropubUpdate { pub fn check_validity(&self) -> Result<(), MicropubError> { if let Some(add) = &self.add { - if add.iter().map(|(k, _)| k.as_str()).any(|k| { - k.to_lowercase().as_str() == "uid" - }) { + if add + .iter() + .map(|(k, _)| k.as_str()) + .any(|k| k.to_lowercase().as_str() == "uid") + { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } if let Some(replace) = &self.replace { - if replace.iter().map(|(k, _)| k.as_str()).any(|k| { - k.to_lowercase().as_str() == "uid" - }) { + if replace + .iter() + .map(|(k, _)| k.as_str()) + .any(|k| k.to_lowercase().as_str() == "uid") + { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } let iter = match &self.delete { Some(MicropubPropertyDeletion::Properties(keys)) => { Some(Box::new(keys.iter().map(|k| k.as_str())) as Box<dyn Iterator<Item = &str>>) - }, + } Some(MicropubPropertyDeletion::Values(map)) => { Some(Box::new(map.iter().map(|(k, _)| k.as_str())) as Box<dyn Iterator<Item = &str>>) - }, + } None => None, }; if let Some(mut iter) = iter { if iter.any(|k| k.to_lowercase().as_str() == "uid") { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } @@ -411,8 +434,9 @@ impl MicropubUpdate { } else if let Some(MicropubPropertyDeletion::Values(ref delete)) = self.delete { if let Some(props) = post["properties"].as_object_mut() { for (key, values) in delete { - if let Some(prop) = props.get_mut(key).and_then(serde_json::Value::as_array_mut) { - prop.retain(|v| { values.iter().all(|i| i != v) }) + if let Some(prop) = props.get_mut(key).and_then(serde_json::Value::as_array_mut) + { + prop.retain(|v| values.iter().all(|i| i != v)) } } } @@ -427,7 +451,10 @@ impl MicropubUpdate { if let Some(add) = self.add { if let Some(props) = post["properties"].as_object_mut() { for (key, value) in add { - if let Some(prop) = props.get_mut(&key).and_then(serde_json::Value::as_array_mut) { + if let Some(prop) = props + .get_mut(&key) + .and_then(serde_json::Value::as_array_mut) + { prop.extend_from_slice(value.as_slice()); } else { props.insert(key, serde_json::Value::Array(value)); @@ -444,7 +471,7 @@ impl From<MicropubFormAction> for MicropubAction { Self { action: a.action, url: a.url, - update: None + update: None, } } } @@ -457,10 +484,12 @@ async fn post_action<D: Storage, A: AuthBackend>( ) -> Result<(), MicropubError> { let uri = match action.url.parse::<hyper::Uri>() { Ok(uri) => uri, - Err(err) => return Err(MicropubError::new( - ErrorKind::InvalidRequest, - format!("url parsing error: {}", err) - )) + Err(err) => { + return Err(MicropubError::new( + ErrorKind::InvalidRequest, + format!("url parsing error: {}", err), + )) + } }; if uri.authority().unwrap() @@ -474,7 +503,7 @@ async fn post_action<D: Storage, A: AuthBackend>( { return Err(MicropubError::from_static( ErrorKind::Forbidden, - "Don't tamper with others' posts!" + "Don't tamper with others' posts!", )); } @@ -483,7 +512,7 @@ async fn post_action<D: Storage, A: AuthBackend>( if !user.check_scope(&Scope::Delete) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "You need a \"delete\" scope for this." + "You need a \"delete\" scope for this.", )); } @@ -493,7 +522,7 @@ async fn post_action<D: Storage, A: AuthBackend>( if !user.check_scope(&Scope::Update) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "You need an \"update\" scope for this." + "You need an \"update\" scope for this.", )); } @@ -502,7 +531,7 @@ async fn post_action<D: Storage, A: AuthBackend>( } else { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update request is not set." + "Update request is not set.", )); }; @@ -554,7 +583,7 @@ async fn dispatch_body( } else { Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid JSON object passed." + "Invalid JSON object passed.", )) } } else if content_type == ContentType::form_url_encoded() { @@ -565,7 +594,7 @@ async fn dispatch_body( } else { Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid form-encoded data. Try h=entry&content=Hello!" + "Invalid form-encoded data. Try h=entry&content=Hello!", )) } } else { @@ -591,8 +620,8 @@ pub(crate) async fn post<D: Storage + 'static, A: AuthBackend>( Err(err) => err.into_response(), }, Ok(PostBody::MF2(mf2)) => { - let (uid, mf2) = normalize_mf2(mf2, &user); - match _post(&user, uid, mf2, db, http, jobset).await { + let NormalizedPost { id, post } = normalize_mf2(mf2, &user); + match _post(&user, id, post, db, http, jobset).await { Ok(response) => response, Err(err) => err.into_response(), } @@ -604,7 +633,10 @@ pub(crate) async fn post<D: Storage + 'static, A: AuthBackend>( #[tracing::instrument(skip(db))] pub(crate) async fn query<D: Storage, A: AuthBackend>( State(db): State<D>, - query: Result<Query<MicropubQuery>, <Query<MicropubQuery> as axum::extract::FromRequestParts<()>>::Rejection>, + query: Result< + Query<MicropubQuery>, + <Query<MicropubQuery> as axum::extract::FromRequestParts<()>>::Rejection, + >, Host(host): Host, user: User<A>, ) -> axum::response::Response { @@ -615,8 +647,9 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( } else { return MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid query provided. Try ?q=config to see what you can do." - ).into_response(); + "Invalid query provided. Try ?q=config to see what you can do.", + ) + .into_response(); }; if axum::http::Uri::try_from(user.me.as_str()) @@ -629,7 +662,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::NotAuthorized, "This website doesn't belong to you.", ) - .into_response(); + .into_response(); } // TODO: consider replacing by `user.me.authority()`? @@ -643,7 +676,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InternalServerError, format!("Error fetching channels: {}", err), ) - .into_response() + .into_response() } }; @@ -653,35 +686,36 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( QueryType::Config, QueryType::Channel, QueryType::SyndicateTo, - QueryType::Category + QueryType::Category, ], channels: Some(channels), syndicate_to: None, media_endpoint: Some(user.me.join("/.kittybox/media").unwrap()), other: { let mut map = std::collections::HashMap::new(); - map.insert("kittybox_authority".to_string(), serde_json::Value::String(user.me.to_string())); + map.insert( + "kittybox_authority".to_string(), + serde_json::Value::String(user.me.to_string()), + ); map - } + }, }) - .into_response() + .into_response() } QueryType::Source => { match query.url { - Some(url) => { - match db.get_post(&url).await { - Ok(some) => match some { - Some(post) => axum::response::Json(&post).into_response(), - None => MicropubError::from_static( - ErrorKind::NotFound, - "The specified MF2 object was not found in database.", - ) - .into_response(), - }, - Err(err) => MicropubError::from(err).into_response(), - } - } + Some(url) => match db.get_post(&url).await { + Ok(some) => match some { + Some(post) => axum::response::Json(&post).into_response(), + None => MicropubError::from_static( + ErrorKind::NotFound, + "The specified MF2 object was not found in database.", + ) + .into_response(), + }, + Err(err) => MicropubError::from(err).into_response(), + }, None => { // Here, one should probably attempt to query at least the main feed and collect posts // Using a pre-made query function can't be done because it does unneeded filtering @@ -690,7 +724,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InvalidRequest, "Querying for post list is not implemented yet.", ) - .into_response() + .into_response() } } } @@ -700,46 +734,45 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InternalServerError, format!("error fetching channels: backend error: {}", err), ) - .into_response(), + .into_response(), }, QueryType::SyndicateTo => { axum::response::Json(json!({ "syndicate-to": [] })).into_response() - }, + } QueryType::Category => { let categories = match db.categories(user_domain).await { Ok(categories) => categories, Err(err) => { return MicropubError::new( ErrorKind::InternalServerError, - format!("error fetching categories: backend error: {}", err) - ).into_response() + format!("error fetching categories: backend error: {}", err), + ) + .into_response() } }; axum::response::Json(json!({ "categories": categories })).into_response() - }, - QueryType::Unknown(q) => return MicropubError::new( - ErrorKind::InvalidRequest, - format!("Invalid query: {}", q) - ).into_response(), + } + QueryType::Unknown(q) => { + return MicropubError::new(ErrorKind::InvalidRequest, format!("Invalid query: {}", q)) + .into_response() + } } } - pub fn router<A, S, St: Send + Sync + Clone + 'static>() -> axum::routing::MethodRouter<St> where S: Storage + FromRef<St> + 'static, A: AuthBackend + FromRef<St>, reqwest_middleware::ClientWithMiddleware: FromRef<St>, - Arc<Mutex<JoinSet<()>>>: FromRef<St> + Arc<Mutex<JoinSet<()>>>: FromRef<St>, { axum::routing::get(query::<S, A>) .post(post::<S, A>) - .layer::<_, _>(tower_http::cors::CorsLayer::new() - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - ]) - .allow_origin(tower_http::cors::Any)) + .layer::<_, _>( + tower_http::cors::CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin(tower_http::cors::Any), + ) } #[cfg(test)] @@ -764,16 +797,19 @@ impl MicropubQuery { mod tests { use std::sync::Arc; - use crate::{database::Storage, micropub::MicropubError}; + use crate::{ + database::Storage, + micropub::{util::NormalizedPost, MicropubError}, + }; use bytes::Bytes; use futures::StreamExt; use serde_json::json; use tokio::sync::Mutex; use super::FetchedPostContext; - use kittybox_indieauth::{Scopes, Scope, TokenData}; use axum::extract::State; use axum_extra::extract::Host; + use kittybox_indieauth::{Scope, Scopes, TokenData}; #[test] fn test_populate_reply_context() { @@ -800,16 +836,27 @@ mod tests { } }); let fetched_ctx_url: url::Url = "https://fireburn.ru/posts/example".parse().unwrap(); - let reply_contexts = vec![(fetched_ctx_url.clone(), FetchedPostContext { - url: fetched_ctx_url.clone(), - mf2: json!({ "items": [test_ctx] }), - webmention: None, - })].into_iter().collect(); + let reply_contexts = vec![( + fetched_ctx_url.clone(), + FetchedPostContext { + url: fetched_ctx_url.clone(), + mf2: json!({ "items": [test_ctx] }), + webmention: None, + }, + )] + .into_iter() + .collect(); let like_of = super::populate_reply_context(&mf2, "like-of", &reply_contexts).unwrap(); - assert_eq!(like_of[0]["properties"]["content"], test_ctx["properties"]["content"]); - assert_eq!(like_of[0]["properties"]["url"][0].as_str().unwrap(), reply_contexts[&fetched_ctx_url].url.as_str()); + assert_eq!( + like_of[0]["properties"]["content"], + test_ctx["properties"]["content"] + ); + assert_eq!( + like_of[0]["properties"]["url"][0].as_str().unwrap(), + reply_contexts[&fetched_ctx_url].url.as_str() + ); assert_eq!(like_of[1], already_expanded_reply_ctx); assert_eq!(like_of[2], "https://fireburn.ru/posts/non-existent"); @@ -829,20 +876,21 @@ mod tests { me: "https://localhost:8080/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), scope: Scopes::new(vec![Scope::Profile]), - iat: None, exp: None + iat: None, + exp: None, }; - let (uid, mf2) = super::normalize_mf2(post, &user); + let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let err = super::_post( - &user, uid, mf2, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap_err(); + .await + .unwrap_err(); assert_eq!(err.error, super::ErrorKind::InvalidScope); @@ -865,21 +913,27 @@ mod tests { let user = TokenData { me: "https://aaronparecki.com/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), - scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), - iat: None, exp: None + scope: Scopes::new(vec![ + Scope::Profile, + Scope::Create, + Scope::Update, + Scope::Media, + ]), + iat: None, + exp: None, }; - let (uid, mf2) = super::normalize_mf2(post, &user); + let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let err = super::_post( - &user, uid, mf2, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap_err(); + .await + .unwrap_err(); assert_eq!(err.error, super::ErrorKind::Forbidden); @@ -901,20 +955,21 @@ mod tests { me: "https://localhost:8080/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), scope: Scopes::new(vec![Scope::Profile, Scope::Create]), - iat: None, exp: None + iat: None, + exp: None, }; - let (uid, mf2) = super::normalize_mf2(post, &user); + let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let res = super::_post( - &user, uid, mf2, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap(); + .await + .unwrap(); assert!(res.headers().contains_key("Location")); let location = res.headers().get("Location").unwrap(); @@ -937,10 +992,17 @@ mod tests { TokenData { me: "https://fireburn.ru/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), - scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), - iat: None, exp: None - }, std::marker::PhantomData - ) + scope: Scopes::new(vec![ + Scope::Profile, + Scope::Create, + Scope::Update, + Scope::Media, + ]), + iat: None, + exp: None, + }, + std::marker::PhantomData, + ), ) .await; @@ -953,7 +1015,10 @@ mod tests { .into_iter() .map(Result::unwrap) .by_ref() - .fold(Vec::new(), |mut a, i| { a.extend(i); a}); + .fold(Vec::new(), |mut a, i| { + a.extend(i); + a + }); let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap(); assert_eq!(json.error, super::ErrorKind::NotAuthorized); } diff --git a/src/micropub/util.rs b/src/micropub/util.rs index 19f4953..8c5d5e9 100644 --- a/src/micropub/util.rs +++ b/src/micropub/util.rs @@ -1,7 +1,7 @@ use crate::database::Storage; -use kittybox_indieauth::TokenData; use chrono::prelude::*; use core::iter::Iterator; +use kittybox_indieauth::TokenData; use newbase60::num_to_sxg; use serde_json::json; use std::convert::TryInto; @@ -33,7 +33,12 @@ fn reset_dt(post: &mut serde_json::Value) -> DateTime<FixedOffset> { chrono::DateTime::from(curtime) } -pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> (String, serde_json::Value) { +pub struct NormalizedPost { + pub id: String, + pub post: serde_json::Value, +} + +pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> NormalizedPost { // Normalize the MF2 object here. let me = &user.me; let folder = get_folder_from_type(body["type"][0].as_str().unwrap()); @@ -137,12 +142,12 @@ pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> (String, } // If there is no explicit channels, and the post is not marked as "unlisted", // post it to one of the default channels that makes sense for the post type. - if body["properties"]["channel"][0].as_str().is_none() && (!body["properties"]["visibility"] - .as_array() - .map(|v| v.contains( - &serde_json::Value::String("unlisted".to_owned()) - )).unwrap_or(false) - ) { + if body["properties"]["channel"][0].as_str().is_none() + && (!body["properties"]["visibility"] + .as_array() + .map(|v| v.contains(&serde_json::Value::String("unlisted".to_owned()))) + .unwrap_or(false)) + { match body["type"][0].as_str() { Some("h-entry") => { // Set the channel to the main channel... @@ -176,10 +181,10 @@ pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> (String, } // TODO: maybe highlight #hashtags? // Find other processing to do and insert it here - return ( - body["properties"]["uid"][0].as_str().unwrap().to_string(), - body, - ); + NormalizedPost { + id: body["properties"]["uid"][0].as_str().unwrap().to_string(), + post: body, + } } pub(crate) fn form_to_mf2_json(form: Vec<(String, String)>) -> serde_json::Value { @@ -219,7 +224,7 @@ pub(crate) async fn create_feed( _ => panic!("Tried to create an unknown default feed!"), }; - let (_, feed) = normalize_mf2( + let NormalizedPost { id: _, post: feed } = normalize_mf2( json!({ "type": ["h-feed"], "properties": { @@ -244,7 +249,7 @@ mod tests { client_id: "https://quill.p3k.io/".parse().unwrap(), scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]), exp: Some(u64::MAX), - iat: Some(0) + iat: Some(0), } } @@ -274,12 +279,15 @@ mod tests { } }); - let (uid, normalized) = normalize_mf2( - mf2.clone(), - &token_data() - ); + let NormalizedPost { + id: _, + post: normalized, + } = normalize_mf2(mf2.clone(), &token_data()); assert!( - normalized["properties"]["channel"].as_array().unwrap_or(&vec![]).is_empty(), + normalized["properties"]["channel"] + .as_array() + .unwrap_or(&vec![]) + .is_empty(), "Returned post was added to a channel despite the `unlisted` visibility" ); } @@ -295,16 +303,16 @@ mod tests { } }); - let (uid, normalized) = normalize_mf2( - mf2.clone(), - &token_data(), - ); + let NormalizedPost { + id, + post: normalized, + } = normalize_mf2(mf2.clone(), &token_data()); assert_eq!( normalized["properties"]["uid"][0], mf2["properties"]["uid"][0], "UID was replaced" ); assert_eq!( - normalized["properties"]["uid"][0], uid, + normalized["properties"]["uid"][0], id, "Returned post location doesn't match UID" ); } @@ -320,10 +328,10 @@ mod tests { } }); - let (_, normalized) = normalize_mf2( - mf2.clone(), - &token_data(), - ); + let NormalizedPost { + id: _, + post: normalized, + } = normalize_mf2(mf2.clone(), &token_data()); assert_eq!( normalized["properties"]["channel"], @@ -342,10 +350,10 @@ mod tests { } }); - let (_, normalized) = normalize_mf2( - mf2.clone(), - &token_data(), - ); + let NormalizedPost { + id: _, + post: normalized, + } = normalize_mf2(mf2.clone(), &token_data()); assert_eq!( normalized["properties"]["channel"][0], @@ -362,10 +370,7 @@ mod tests { } }); - let (uid, post) = normalize_mf2( - mf2, - &token_data(), - ); + let NormalizedPost { id, post } = normalize_mf2(mf2, &token_data()); assert_eq!( post["properties"]["published"] .as_array() @@ -392,11 +397,11 @@ mod tests { "Post doesn't have a single UID" ); assert_eq!( - post["properties"]["uid"][0], uid, + post["properties"]["uid"][0], id, "UID of a post and its supposed location don't match" ); assert!( - uid.starts_with("https://fireburn.ru/posts/"), + id.starts_with("https://fireburn.ru/posts/"), "The post namespace is incorrect" ); assert_eq!( @@ -427,10 +432,7 @@ mod tests { }, }); - let (_, post) = normalize_mf2( - mf2, - &token_data(), - ); + let NormalizedPost { id: _, post } = normalize_mf2(mf2, &token_data()); assert!( post["properties"]["url"] .as_array() @@ -456,12 +458,9 @@ mod tests { } }); - let (uid, post) = normalize_mf2( - mf2, - &token_data(), - ); + let NormalizedPost { id, post } = normalize_mf2(mf2, &token_data()); assert_eq!( - post["properties"]["uid"][0], uid, + post["properties"]["uid"][0], id, "UID of a post and its supposed location don't match" ); assert_eq!(post["properties"]["author"][0], "https://fireburn.ru/"); diff --git a/src/webmentions/check.rs b/src/webmentions/check.rs index 683cc6b..380f4db 100644 --- a/src/webmentions/check.rs +++ b/src/webmentions/check.rs @@ -1,7 +1,7 @@ -use std::rc::Rc; -use microformats::types::PropertyValue; use html5ever::{self, tendril::TendrilSink}; use kittybox_util::MentionType; +use microformats::types::PropertyValue; +use std::rc::Rc; // TODO: replace. mod rcdom; @@ -17,7 +17,11 @@ pub enum Error { } #[tracing::instrument] -pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url::Url, link: &url::Url) -> Result<Option<(MentionType, serde_json::Value)>, Error> { +pub fn check_mention( + document: impl AsRef<str> + std::fmt::Debug, + base_url: &url::Url, + link: &url::Url, +) -> Result<Option<(MentionType, serde_json::Value)>, Error> { tracing::debug!("Parsing MF2 markup..."); // First, check the document for MF2 markup let document = microformats::from_html(document.as_ref(), base_url.clone())?; @@ -29,8 +33,10 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url tracing::debug!("Processing item: {:?}", item); for (prop, interaction_type) in [ - ("in-reply-to", MentionType::Reply), ("like-of", MentionType::Like), - ("bookmark-of", MentionType::Bookmark), ("repost-of", MentionType::Repost) + ("in-reply-to", MentionType::Reply), + ("like-of", MentionType::Like), + ("bookmark-of", MentionType::Bookmark), + ("repost-of", MentionType::Repost), ] { if let Some(propvals) = item.properties.get(prop) { tracing::debug!("Has a u-{} property", prop); @@ -38,7 +44,10 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url if let PropertyValue::Url(url) = val { if url == link { tracing::debug!("URL matches! Webmention is valid"); - return Ok(Some((interaction_type, serde_json::to_value(item).unwrap()))) + return Ok(Some(( + interaction_type, + serde_json::to_value(item).unwrap(), + ))); } } } @@ -46,7 +55,9 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url } // Process `content` tracing::debug!("Processing e-content..."); - if let Some(PropertyValue::Fragment(content)) = item.properties.get("content") + if let Some(PropertyValue::Fragment(content)) = item + .properties + .get("content") .map(Vec::as_slice) .unwrap_or_default() .first() @@ -65,7 +76,8 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url // iteration of the loop. // // Empty list means all nodes were processed. - let mut unprocessed_nodes: Vec<Rc<rcdom::Node>> = root.children.borrow().iter().cloned().collect(); + let mut unprocessed_nodes: Vec<Rc<rcdom::Node>> = + root.children.borrow().iter().cloned().collect(); while !unprocessed_nodes.is_empty() { // "Take" the list out of its memory slot, replace it with an empty list let nodes = std::mem::take(&mut unprocessed_nodes); @@ -74,15 +86,23 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url // Add children nodes to the list for the next iteration unprocessed_nodes.extend(node.children.borrow().iter().cloned()); - if let rcdom::NodeData::Element { ref name, ref attrs, .. } = node.data { + if let rcdom::NodeData::Element { + ref name, + ref attrs, + .. + } = node.data + { // If it's not `<a>`, skip it - if name.local != *"a" { continue; } + if name.local != *"a" { + continue; + } let mut is_mention: bool = false; for attr in attrs.borrow().iter() { if attr.name.local == *"rel" { // Don't count `rel="nofollow"` links — a web crawler should ignore them // and so for purposes of driving visitors they are useless - if attr.value + if attr + .value .as_ref() .split([',', ' ']) .any(|v| v == "nofollow") @@ -92,7 +112,9 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url } } // if it's not `<a href="...">`, skip it - if attr.name.local != *"href" { continue; } + if attr.name.local != *"href" { + continue; + } // Be forgiving in parsing URLs, and resolve them against the base URL if let Ok(url) = base_url.join(attr.value.as_ref()) { if &url == link { @@ -101,12 +123,14 @@ pub fn check_mention(document: impl AsRef<str> + std::fmt::Debug, base_url: &url } } if is_mention { - return Ok(Some((MentionType::Mention, serde_json::to_value(item).unwrap()))); + return Ok(Some(( + MentionType::Mention, + serde_json::to_value(item).unwrap(), + ))); } } } } - } } diff --git a/src/webmentions/mod.rs b/src/webmentions/mod.rs index 91b274b..57f9a57 100644 --- a/src/webmentions/mod.rs +++ b/src/webmentions/mod.rs @@ -1,9 +1,14 @@ -use axum::{extract::{FromRef, State}, response::{IntoResponse, Response}, routing::post, Form}; use axum::http::StatusCode; +use axum::{ + extract::{FromRef, State}, + response::{IntoResponse, Response}, + routing::post, + Form, +}; use tracing::error; -use crate::database::{Storage, StorageError}; use self::queue::JobQueue; +use crate::database::{Storage, StorageError}; pub mod queue; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -24,40 +29,46 @@ async fn accept_webmention<Q: JobQueue<Webmention>>( Form(webmention): Form<Webmention>, ) -> Response { if let Err(err) = webmention.source.parse::<url::Url>() { - return (StatusCode::BAD_REQUEST, err.to_string()).into_response() + return (StatusCode::BAD_REQUEST, err.to_string()).into_response(); } if let Err(err) = webmention.target.parse::<url::Url>() { - return (StatusCode::BAD_REQUEST, err.to_string()).into_response() + return (StatusCode::BAD_REQUEST, err.to_string()).into_response(); } match queue.put(&webmention).await { Ok(_id) => StatusCode::ACCEPTED.into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, [ - ("Content-Type", "text/plain") - ], err.to_string()).into_response() + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [("Content-Type", "text/plain")], + err.to_string(), + ) + .into_response(), } } -pub fn router<St: Clone + Send + Sync + 'static, Q: JobQueue<Webmention> + FromRef<St>>() -> axum::Router<St> { - axum::Router::new() - .route("/.kittybox/webmention", post(accept_webmention::<Q>)) +pub fn router<St: Clone + Send + Sync + 'static, Q: JobQueue<Webmention> + FromRef<St>>( +) -> axum::Router<St> { + axum::Router::new().route("/.kittybox/webmention", post(accept_webmention::<Q>)) } #[derive(thiserror::Error, Debug)] pub enum SupervisorError { #[error("the task was explicitly cancelled")] - Cancelled + Cancelled, } -pub type SupervisedTask = tokio::task::JoinHandle<Result<std::convert::Infallible, SupervisorError>>; +pub type SupervisedTask = + tokio::task::JoinHandle<Result<std::convert::Infallible, SupervisorError>>; -pub fn supervisor<E, A, F>(mut f: F, cancellation_token: tokio_util::sync::CancellationToken) -> SupervisedTask +pub fn supervisor<E, A, F>( + mut f: F, + cancellation_token: tokio_util::sync::CancellationToken, +) -> SupervisedTask where E: std::error::Error + std::fmt::Debug + Send + 'static, A: std::future::Future<Output = Result<std::convert::Infallible, E>> + Send + 'static, - F: FnMut() -> A + Send + 'static + F: FnMut() -> A + Send + 'static, { - let supervisor_future = async move { loop { // Don't spawn the task if we are already cancelled, but @@ -65,7 +76,7 @@ where // crashed and we immediately received a cancellation // request after noticing the crashed task) if cancellation_token.is_cancelled() { - return Err(SupervisorError::Cancelled) + return Err(SupervisorError::Cancelled); } let task = tokio::task::spawn(f()); tokio::select! { @@ -87,7 +98,13 @@ where return tokio::task::spawn(supervisor_future); #[cfg(tokio_unstable)] return tokio::task::Builder::new() - .name(format!("supervisor for background task {}", std::any::type_name::<A>()).as_str()) + .name( + format!( + "supervisor for background task {}", + std::any::type_name::<A>() + ) + .as_str(), + ) .spawn(supervisor_future) .unwrap(); } @@ -99,39 +116,55 @@ enum Error<Q: std::error::Error + std::fmt::Debug + Send + 'static> { #[error("queue error: {0}")] Queue(#[from] Q), #[error("storage error: {0}")] - Storage(StorageError) + Storage(StorageError), } -async fn process_webmentions_from_queue<Q: JobQueue<Webmention>, S: Storage + 'static>(queue: Q, db: S, http: reqwest_middleware::ClientWithMiddleware) -> Result<std::convert::Infallible, Error<Q::Error>> { - use futures_util::StreamExt; +async fn process_webmentions_from_queue<Q: JobQueue<Webmention>, S: Storage + 'static>( + queue: Q, + db: S, + http: reqwest_middleware::ClientWithMiddleware, +) -> Result<std::convert::Infallible, Error<Q::Error>> { use self::queue::Job; + use futures_util::StreamExt; let mut stream = queue.into_stream().await?; while let Some(item) = stream.next().await.transpose()? { let job = item.job(); let (source, target) = ( job.source.parse::<url::Url>().unwrap(), - job.target.parse::<url::Url>().unwrap() + job.target.parse::<url::Url>().unwrap(), ); let (code, text) = match http.get(source.clone()).send().await { Ok(response) => { let code = response.status(); - if ![StatusCode::OK, StatusCode::GONE].iter().any(|i| i == &code) { - error!("error processing webmention: webpage fetch returned {}", code); + if ![StatusCode::OK, StatusCode::GONE] + .iter() + .any(|i| i == &code) + { + error!( + "error processing webmention: webpage fetch returned {}", + code + ); continue; } match response.text().await { Ok(text) => (code, text), Err(err) => { - error!("error processing webmention: error fetching webpage text: {}", err); - continue + error!( + "error processing webmention: error fetching webpage text: {}", + err + ); + continue; } } } Err(err) => { - error!("error processing webmention: error requesting webpage: {}", err); - continue + error!( + "error processing webmention: error requesting webpage: {}", + err + ); + continue; } }; @@ -150,7 +183,10 @@ async fn process_webmentions_from_queue<Q: JobQueue<Webmention>, S: Storage + 's continue; } Err(err) => { - error!("error processing webmention: error checking webmention: {}", err); + error!( + "error processing webmention: error checking webmention: {}", + err + ); continue; } }; @@ -158,31 +194,47 @@ async fn process_webmentions_from_queue<Q: JobQueue<Webmention>, S: Storage + 's { mention["type"] = serde_json::json!(["h-cite"]); - if !mention["properties"].as_object().unwrap().contains_key("uid") { - let url = mention["properties"]["url"][0].as_str().unwrap_or_else(|| target.as_str()).to_owned(); + if !mention["properties"] + .as_object() + .unwrap() + .contains_key("uid") + { + let url = mention["properties"]["url"][0] + .as_str() + .unwrap_or_else(|| target.as_str()) + .to_owned(); let props = mention["properties"].as_object_mut().unwrap(); - props.insert("uid".to_owned(), serde_json::Value::Array( - vec![serde_json::Value::String(url)]) + props.insert( + "uid".to_owned(), + serde_json::Value::Array(vec![serde_json::Value::String(url)]), ); } } - db.add_or_update_webmention(target.as_str(), mention_type, mention).await.map_err(Error::<Q::Error>::Storage)?; + db.add_or_update_webmention(target.as_str(), mention_type, mention) + .await + .map_err(Error::<Q::Error>::Storage)?; } } unreachable!() } -pub fn supervised_webmentions_task<St: Send + Sync + 'static, S: Storage + FromRef<St> + 'static, Q: JobQueue<Webmention> + FromRef<St> + 'static>( +pub fn supervised_webmentions_task< + St: Send + Sync + 'static, + S: Storage + FromRef<St> + 'static, + Q: JobQueue<Webmention> + FromRef<St> + 'static, +>( state: &St, - cancellation_token: tokio_util::sync::CancellationToken + cancellation_token: tokio_util::sync::CancellationToken, ) -> SupervisedTask -where reqwest_middleware::ClientWithMiddleware: FromRef<St> +where + reqwest_middleware::ClientWithMiddleware: FromRef<St>, { let queue = Q::from_ref(state); let storage = S::from_ref(state); let http = reqwest_middleware::ClientWithMiddleware::from_ref(state); - supervisor::<Error<Q::Error>, _, _>(move || process_webmentions_from_queue( - queue.clone(), storage.clone(), http.clone() - ), cancellation_token) + supervisor::<Error<Q::Error>, _, _>( + move || process_webmentions_from_queue(queue.clone(), storage.clone(), http.clone()), + cancellation_token, + ) } diff --git a/src/webmentions/queue.rs b/src/webmentions/queue.rs index 52bcdfa..a33de1a 100644 --- a/src/webmentions/queue.rs +++ b/src/webmentions/queue.rs @@ -6,7 +6,7 @@ use super::Webmention; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/webmention"); -pub use kittybox_util::queue::{JobQueue, JobItem, Job, JobStream}; +pub use kittybox_util::queue::{Job, JobItem, JobQueue, JobStream}; pub trait PostgresJobItem: JobItem + sqlx::FromRow<'static, sqlx::postgres::PgRow> { const DATABASE_NAME: &'static str; @@ -17,7 +17,7 @@ pub trait PostgresJobItem: JobItem + sqlx::FromRow<'static, sqlx::postgres::PgRo struct PostgresJobRow<T: PostgresJobItem> { id: Uuid, #[sqlx(flatten)] - job: T + job: T, } #[derive(Debug)] @@ -29,7 +29,6 @@ pub struct PostgresJob<T: PostgresJobItem> { runtime_handle: tokio::runtime::Handle, } - impl<T: PostgresJobItem> Drop for PostgresJob<T> { // This is an emulation of "async drop" — the struct retains a // runtime handle, which it uses to block on a future that does @@ -87,7 +86,9 @@ impl Job<Webmention, PostgresJobQueue<Webmention>> for PostgresJob<Webmention> { fn job(&self) -> &Webmention { &self.job } - async fn done(mut self) -> Result<(), <PostgresJobQueue<Webmention> as JobQueue<Webmention>>::Error> { + async fn done( + mut self, + ) -> Result<(), <PostgresJobQueue<Webmention> as JobQueue<Webmention>>::Error> { tracing::debug!("Deleting {} from the job queue", self.id); sqlx::query("DELETE FROM kittybox_webmention.incoming_webmention_queue WHERE id = $1") .bind(self.id) @@ -100,13 +101,13 @@ impl Job<Webmention, PostgresJobQueue<Webmention>> for PostgresJob<Webmention> { pub struct PostgresJobQueue<T> { db: sqlx::PgPool, - _phantom: std::marker::PhantomData<T> + _phantom: std::marker::PhantomData<T>, } impl<T> Clone for PostgresJobQueue<T> { fn clone(&self) -> Self { Self { db: self.db.clone(), - _phantom: std::marker::PhantomData + _phantom: std::marker::PhantomData, } } } @@ -120,15 +121,21 @@ impl PostgresJobQueue<Webmention> { sqlx::postgres::PgPoolOptions::new() .max_connections(50) .connect_with(options) - .await? - ).await - + .await?, + ) + .await } pub(crate) async fn from_pool(db: sqlx::PgPool) -> Result<Self, sqlx::Error> { - db.execute(sqlx::query("CREATE SCHEMA IF NOT EXISTS kittybox_webmention")).await?; + db.execute(sqlx::query( + "CREATE SCHEMA IF NOT EXISTS kittybox_webmention", + )) + .await?; MIGRATOR.run(&db).await?; - Ok(Self { db, _phantom: std::marker::PhantomData }) + Ok(Self { + db, + _phantom: std::marker::PhantomData, + }) } } @@ -180,13 +187,14 @@ impl JobQueue<Webmention> for PostgresJobQueue<Webmention> { Some(item) => return Ok(Some((item, ()))), None => { listener.lock().await.recv().await?; - continue + continue; } } } } } - }).boxed(); + }) + .boxed(); Ok(stream) } @@ -196,7 +204,7 @@ impl JobQueue<Webmention> for PostgresJobQueue<Webmention> { mod tests { use std::sync::Arc; - use super::{Webmention, PostgresJobQueue, Job, JobQueue, MIGRATOR}; + use super::{Job, JobQueue, PostgresJobQueue, Webmention, MIGRATOR}; use futures_util::StreamExt; #[sqlx::test(migrator = "MIGRATOR")] @@ -204,7 +212,7 @@ mod tests { async fn test_webmention_queue(pool: sqlx::PgPool) -> Result<(), sqlx::Error> { let test_webmention = Webmention { source: "https://fireburn.ru/posts/lorem-ipsum".to_owned(), - target: "https://aaronparecki.com/posts/dolor-sit-amet".to_owned() + target: "https://aaronparecki.com/posts/dolor-sit-amet".to_owned(), }; let queue = PostgresJobQueue::<Webmention>::from_pool(pool).await?; @@ -236,7 +244,7 @@ mod tests { match queue.get_one().await? { Some(item) => panic!("Unexpected item {:?} returned from job queue!", item), - None => Ok(()) + None => Ok(()), } } @@ -245,7 +253,7 @@ mod tests { async fn test_no_hangups_in_queue(pool: sqlx::PgPool) -> Result<(), sqlx::Error> { let test_webmention = Webmention { source: "https://fireburn.ru/posts/lorem-ipsum".to_owned(), - target: "https://aaronparecki.com/posts/dolor-sit-amet".to_owned() + target: "https://aaronparecki.com/posts/dolor-sit-amet".to_owned(), }; let queue = PostgresJobQueue::<Webmention>::from_pool(pool.clone()).await?; @@ -272,18 +280,18 @@ mod tests { } }); } - tokio::time::timeout(std::time::Duration::from_secs(1), stream.next()).await.unwrap_err(); + tokio::time::timeout(std::time::Duration::from_secs(1), stream.next()) + .await + .unwrap_err(); - let future = tokio::task::spawn( - tokio::time::timeout( - std::time::Duration::from_secs(10), async move { - stream.next().await.unwrap().unwrap() - } - ) - ); + let future = tokio::task::spawn(tokio::time::timeout( + std::time::Duration::from_secs(10), + async move { stream.next().await.unwrap().unwrap() }, + )); // Let the other task drop the guard it is holding barrier.wait().await; - let mut guard = future.await + let mut guard = future + .await .expect("Timeout on fetching item") .expect("Job queue error"); assert_eq!(guard.job(), &test_webmention); diff --git a/templates-neo/Cargo.toml b/templates-neo/Cargo.toml deleted file mode 100644 index 0be4dd2..0000000 --- a/templates-neo/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "kittybox-html" -version = "0.2.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[build-dependencies] -libflate = { workspace = true } -walkdir = { workspace = true } - -[dev-dependencies] -faker_rand = { workspace = true } -rand = { workspace = true } - -[dependencies] -axum = { workspace = true } -chrono = { workspace = true } -ellipse = { workspace = true } -html = { workspace = true } -http = { workspace = true } -include_dir = { workspace = true } -microformats = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -time = { workspace = true, features = ["formatting"] } -url = { workspace = true } - -[dependencies.kittybox-util] -version = "0.3.0" -path = "../util" -[dependencies.kittybox-indieauth] -version = "0.2.0" -path = "../indieauth" diff --git a/templates-neo/src/lib.rs b/templates-neo/src/lib.rs deleted file mode 100644 index 1ae9e03..0000000 --- a/templates-neo/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -#![recursion_limit = "512"] -pub mod mf2; diff --git a/templates-neo/src/main.rs b/templates-neo/src/main.rs deleted file mode 100644 index d374e3f..0000000 --- a/templates-neo/src/main.rs +++ /dev/null @@ -1,18 +0,0 @@ -#![recursion_limit = "512"] -use std::io::Write; - -use kittybox_html::mf2::Entry; - -fn main() { - let mf2 = serde_json::from_reader::<_, microformats::types::Item>(std::io::stdin()).unwrap(); - let entry = Entry::try_from(mf2).unwrap(); - - let mut article = html::content::Article::builder(); - entry.build(&mut article); - - let mut stdout = std::io::stdout().lock(); - stdout - .write_all(article.build().to_string().as_bytes()) - .unwrap(); - stdout.write_all(b"\n").unwrap(); -} diff --git a/templates-neo/src/mf2.rs b/templates-neo/src/mf2.rs deleted file mode 100644 index 3cf453f..0000000 --- a/templates-neo/src/mf2.rs +++ /dev/null @@ -1,467 +0,0 @@ -use std::{borrow::Cow, collections::HashMap}; - -use html::{ - content::builders::{ArticleBuilder, SectionBuilder}, - inline_text::Anchor, - media::builders, -}; -use microformats::types::{ - temporal::Value as Temporal, Class, Fragment, Item, KnownClass, PropertyValue, -}; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("wrong mf2 class, expected {expected:?}, got {got:?}")] - WrongClass { - expected: Vec<KnownClass>, - got: Vec<Class>, - }, - #[error("entry lacks `uid` property")] - NoUid, - #[error("unexpected type of property value: expected {expected}, got {got:?}")] - WrongValueType { - expected: &'static str, - got: PropertyValue, - }, - #[error("missing property: {0}")] - MissingProperty(&'static str), -} - -pub enum Image { - Plain(url::Url), - Accessible { src: url::Url, alt: String }, -} - -impl Image { - pub fn build( - self, - img: &mut html::media::builders::ImageBuilder, - ) -> &mut html::media::builders::ImageBuilder { - match self { - Image::Plain(url) => img.src(String::from(url)), - Image::Accessible { src, alt } => img.src(String::from(src)).alt(alt), - } - } -} - -pub struct Card { - uid: url::Url, - urls: Vec<url::Url>, - name: String, - note: Option<String>, - photo: Image, - pronouns: Vec<String>, -} - -impl TryFrom<Item> for Card { - type Error = Error; - - fn try_from(card: Item) -> Result<Self, Self::Error> { - if card.r#type.as_slice() != [Class::Known(KnownClass::Card)] { - return Err(Error::WrongClass { - expected: vec![KnownClass::Card], - got: card.r#type, - }); - } - - let mut props = card.properties; - let uid = { - let uids = props.remove("uid").ok_or(Error::NoUid)?; - if let Some(PropertyValue::Url(uid)) = uids.into_iter().take(1).next() { - uid - } else { - return Err(Error::NoUid); - } - }; - - Ok(Self { - uid, - urls: props - .remove("url") - .unwrap_or_default() - .into_iter() - .filter_map(|v| { - if let PropertyValue::Url(url) = v { - Some(url) - } else { - None - } - }) - .collect(), - name: props - .remove("name") - .unwrap_or_default() - .into_iter() - .next() - .ok_or(Error::MissingProperty("name")) - .and_then(|v| match v { - PropertyValue::Plain(plain) => Ok(plain), - other => Err(Error::WrongValueType { - expected: "string", - got: other, - }), - })?, - note: props - .remove("note") - .unwrap_or_default() - .into_iter() - .next() - .map(|v| match v { - PropertyValue::Plain(plain) => Ok(plain), - other => Err(Error::WrongValueType { - expected: "string", - got: other, - }), - }) - .transpose()?, - photo: props - .remove("photo") - .unwrap_or_default() - .into_iter() - .next() - .ok_or(Error::MissingProperty("photo")) - .and_then(|v| match v { - PropertyValue::Url(url) => Ok(Image::Plain(url)), - PropertyValue::Image(image) => match image.alt { - Some(alt) => Ok(Image::Accessible { - src: image.value, - alt, - }), - None => Ok(Image::Plain(image.value)) - }, - other => Err(Error::WrongValueType { - expected: "string", - got: other, - }), - })?, - pronouns: props - .remove("pronoun") - .unwrap_or_default() - .into_iter() - .map(|v| match v { - PropertyValue::Plain(plain) => Ok(plain), - other => Err(Error::WrongValueType { - expected: "string", - got: other, - }), - }) - .collect::<Result<Vec<String>, _>>()?, - }) - } -} - -impl Card { - pub fn build_section( - self, - section: &mut html::content::builders::SectionBuilder, - ) -> &mut html::content::builders::SectionBuilder { - section.class("mini-h-card").anchor(|a| { - a.class("larger u-author") - .href(String::from(self.uid)) - .image(move |img| self.photo.build(img).loading("lazy")) - .text(self.name) - }) - } - - pub fn build( - self, - article: &mut html::content::builders::ArticleBuilder, - ) -> &mut html::content::builders::ArticleBuilder { - let urls: Vec<_> = self.urls.into_iter().filter(|u| *u != self.uid).collect(); - - article - .class("h-card") - .image(move |builder| self.photo.build(builder)) - .heading_1(move |builder| { - builder.anchor(|builder| { - builder - .class("u-url u-uid p-name") - .href(String::from(self.uid)) - .text(self.name) - }) - }); - - if !self.pronouns.is_empty() { - article.span(move |span| { - span.text("("); - self.pronouns.into_iter().for_each(|p| { - span.text(p); - }); - span.text(")") - }); - } - - if let Some(note) = self.note { - article.paragraph(move |p| p.class("p-note").text(note)); - } - - if !urls.is_empty() { - article.paragraph(|p| p.text("Can be found elsewhere at:")); - article.unordered_list(move |ul| { - for url in urls { - let url = String::from(url); - ul.list_item(move |li| { - li.push(Anchor::builder().href(url.clone()).text(url).build()) - }); - } - - ul - }); - } - - article - } -} - -impl TryFrom<PropertyValue> for Card { - type Error = Error; - - fn try_from(v: PropertyValue) -> Result<Self, Self::Error> { - match v { - PropertyValue::Item(item) => item.try_into(), - other => Err(Error::WrongValueType { - expected: "h-card", - got: other, - }), - } - } -} - -pub struct Cite { - uid: url::Url, - url: Vec<url::Url>, - in_reply_to: Option<Vec<Citation>>, - author: Card, - published: Option<time::OffsetDateTime>, - content: Content, -} - -impl TryFrom<Item> for Cite { - type Error = Error; - - fn try_from(cite: Item) -> Result<Self, Self::Error> { - if cite.r#type.as_slice() != [Class::Known(KnownClass::Cite)] { - return Err(Error::WrongClass { - expected: vec![KnownClass::Cite], - got: cite.r#type, - }); - } - - todo!() - } -} - -pub enum Citation { - Brief(url::Url), - Full(Cite), -} - -impl TryFrom<PropertyValue> for Citation { - type Error = Error; - fn try_from(v: PropertyValue) -> Result<Self, Self::Error> { - match v { - PropertyValue::Url(url) => Ok(Self::Brief(url)), - PropertyValue::Item(item) => Ok(Self::Full(item.try_into()?)), - other => Err(Error::WrongValueType { - expected: "url or h-cite", - got: other, - }), - } - } -} - -pub struct Content(Fragment); - -impl From<Content> for html::content::Main { - fn from(content: Content) -> Self { - let mut builder = Self::builder(); - builder.class("e-content").text(content.0.html); - if let Some(lang) = content.0.lang { - builder.lang(Cow::Owned(lang)); - } - builder.build() - } -} - -pub struct Entry { - uid: url::Url, - url: Vec<url::Url>, - in_reply_to: Option<Citation>, - author: Card, - category: Vec<String>, - syndication: Vec<url::Url>, - published: time::OffsetDateTime, - content: Content, -} - -impl TryFrom<Item> for Entry { - type Error = Error; - fn try_from(entry: Item) -> Result<Self, Self::Error> { - if entry.r#type.as_slice() != [Class::Known(KnownClass::Entry)] { - return Err(Error::WrongClass { - expected: vec![KnownClass::Entry], - got: entry.r#type, - }); - } - - let mut props = entry.properties; - let uid = { - let uids = props.remove("uid").ok_or(Error::NoUid)?; - if let Some(PropertyValue::Url(uid)) = uids.into_iter().take(1).next() { - uid - } else { - return Err(Error::NoUid); - } - }; - Ok(Entry { - uid, - url: props - .remove("url") - .unwrap_or_default() - .into_iter() - .filter_map(|v| { - if let PropertyValue::Url(url) = v { - Some(url) - } else { - None - } - }) - .collect(), - in_reply_to: props - .remove("in-reply-to") - .unwrap_or_default() - .into_iter() - .next() - .map(|v| v.try_into()) - .transpose()?, - author: props - .remove("author") - .unwrap_or_default() - .into_iter() - .next() - .map(|v| v.try_into()) - .transpose()? - .ok_or(Error::MissingProperty("author"))?, - category: props - .remove("category") - .unwrap_or_default() - .into_iter() - .map(|v| match v { - PropertyValue::Plain(string) => Ok(string), - other => Err(Error::WrongValueType { - expected: "string", - got: other, - }), - }) - .collect::<Result<Vec<_>, _>>()?, - syndication: props - .remove("syndication") - .unwrap_or_default() - .into_iter() - .map(|v| match v { - PropertyValue::Url(url) => Ok(url), - other => Err(Error::WrongValueType { - expected: "link", - got: other, - }), - }) - .collect::<Result<Vec<_>, _>>()?, - published: props - .remove("published") - .unwrap_or_default() - .into_iter() - .next() - .map( - |v| -> Result<time::OffsetDateTime, Error> { - match v { - PropertyValue::Temporal(Temporal::Timestamp(ref dt)) => { - // This is incredibly sketchy. - let (date, time, offset) = ( - dt.date.to_owned().ok_or_else(|| Error::WrongValueType { - expected: "timestamp (date, time, offset)", - got: v.clone() - })?.data, - dt.time.to_owned().ok_or_else(|| Error::WrongValueType { - expected: "timestamp (date, time, offset)", - got: v.clone() - })?.data, - dt.offset.to_owned().ok_or_else(|| Error::WrongValueType { - expected: "timestamp (date, time, offset)", - got: v.clone() - })?.data, - ); - - Ok(date.with_time(time).assume_offset(offset)) - } - other => Err(Error::WrongValueType { - expected: "timestamp", - got: other, - }), - } - }, - ) - .ok_or(Error::MissingProperty("published"))??, - content: props - .remove("content") - .unwrap_or_default() - .into_iter() - .next() - .ok_or(Error::MissingProperty("content")) - .and_then(|v| match v { - PropertyValue::Fragment(fragment) => Ok(Content(fragment)), - other => Err(Error::WrongValueType { - expected: "html", - got: other, - }), - })?, - }) - } -} - -impl Entry { - pub fn build(self, article: &mut ArticleBuilder) -> &mut ArticleBuilder { - article - .class("h-entry") - .header(|header| { - header - .class("metadata") - .section(|section| self.author.build_section(section)) - .section(|section| { - section - .division(|div| { - div.anchor(|a| { - a.class("u-url u-uid").href(String::from(self.uid)).push( - html::inline_text::Time::builder() - .text( - self.published - .format(&time::format_description::well_known::Rfc2822) - .unwrap() - ) - .date_time(self.published.format(&time::format_description::well_known::Rfc3339).unwrap()) - .build(), - ) - }) - }) - .division(|div| { - div.text("Tagged").unordered_list(|ul| { - for category in self.category { - ul.list_item(|li| li.class("p-category").text(category)); - } - - ul - }) - }) - }) - }) - .main(|main| { - if let Some(lang) = self.content.0.lang { - main.lang(lang); - } - - // XXX .text() and .push() are completely equivalent - // since .text() does no escaping - main.push(self.content.0.html) - }) - .footer(|footer| footer) - } -} diff --git a/templates/Cargo.toml b/templates/Cargo.toml index 19855e6..ca56dfe 100644 --- a/templates/Cargo.toml +++ b/templates/Cargo.toml @@ -28,5 +28,5 @@ serde_json = { workspace = true } version = "0.3.0" path = "../util" [dependencies.kittybox-indieauth] -version = "0.2.0" +version = "0.3.0" path = "../indieauth" diff --git a/templates/assets/style.css b/templates/assets/style.css index 6139288..97483d4 100644 --- a/templates/assets/style.css +++ b/templates/assets/style.css @@ -175,6 +175,7 @@ article.h-entry, article.h-feed, article.h-card, article.h-event { } .webinteractions > ul.counters > li > .icon { font-size: 1.5em; + font-family: emoji; } .webinteractions > ul.counters > li { display: inline-flex; @@ -300,11 +301,13 @@ body > a#skip-to-content:focus { white-space: nowrap; width: 1px; } - +/* Extras: styles to demarcate output generated by machine learning models + * (No, LLMs and diffusion image generation models are not artificial intelligence) + */ figure.llm-quote { background: #ddd; border-left: 0.5em solid black; - border-image: repeating-linear-gradient(45deg, #000000, #000000 0.75em, #FFFF00 0.75em, #FFFF00 1.5em) 8; + border-image: repeating-linear-gradient(45deg, #000000, #333333 0.75em, #DDDD00 0.75em, #FFFF00 1.5em) 8; padding: 0.5em; padding-left: 0.75em; margin-left: 3em; @@ -319,3 +322,7 @@ figure.llm-quote > figcaption { background-color: #242424; } } +img.diffusion-model-output { + border-left: 0.5em solid black; + border-image: repeating-linear-gradient(45deg, #000000, #333333 0.75em, #DDDD00 0.75em, #FFFF00 1.5em) 8; +} diff --git a/templates/build.rs b/templates/build.rs index 5a62855..057666b 100644 --- a/templates/build.rs +++ b/templates/build.rs @@ -22,8 +22,7 @@ fn main() -> Result<(), std::io::Error> { println!("cargo:rerun-if-changed=assets/"); let assets_path = std::path::Path::new("assets"); - let mut assets = WalkDir::new(assets_path) - .into_iter(); + let mut assets = WalkDir::new(assets_path).into_iter(); while let Some(Ok(entry)) = assets.next() { eprintln!("Processing {}", entry.path().display()); let out_path = out_dir.join(entry.path().strip_prefix(assets_path).unwrap()); @@ -31,11 +30,15 @@ fn main() -> Result<(), std::io::Error> { eprintln!("Creating directory {}", &out_path.display()); if let Err(err) = std::fs::create_dir(&out_path) { if err.kind() != std::io::ErrorKind::AlreadyExists { - return Err(err) + return Err(err); } } } else { - eprintln!("Copying {} to {}", entry.path().display(), out_path.display()); + eprintln!( + "Copying {} to {}", + entry.path().display(), + out_path.display() + ); std::fs::copy(entry.path(), &out_path)?; } } @@ -43,16 +46,11 @@ fn main() -> Result<(), std::io::Error> { let walker = WalkDir::new(&out_dir) .into_iter() .map(Result::unwrap) - .filter(|e| { - e.file_type().is_file() && e.path().extension().unwrap() != "gz" - }); + .filter(|e| e.file_type().is_file() && e.path().extension().unwrap() != "gz"); for entry in walker { let normal_path = entry.path(); let gzip_path = normal_path.with_extension({ - let mut extension = normal_path - .extension() - .unwrap() - .to_owned(); + let mut extension = normal_path.extension().unwrap().to_owned(); extension.push(OsStr::new(".gz")); extension }); diff --git a/templates/src/assets.rs b/templates/src/assets.rs new file mode 100644 index 0000000..493c14d --- /dev/null +++ b/templates/src/assets.rs @@ -0,0 +1,47 @@ +use axum::extract::Path; +use axum::http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +const ASSETS: include_dir::Dir<'static> = include_dir::include_dir!("$OUT_DIR/"); +const CACHE_FOR_A_DAY: &str = "max-age=86400"; +const GZIP: &str = "gzip"; + +pub async fn statics(Path(path): Path<String>) -> Response { + let content_type: &'static str = if path.ends_with(".js") { + "application/javascript" + } else if path.ends_with(".css") { + "text/css" + } else if path.ends_with(".html") { + "text/html; charset=\"utf-8\"" + } else { + "application/octet-stream" + }; + + match ASSETS.get_file(path.clone() + ".gz") { + Some(file) => ( + StatusCode::OK, + [ + (CONTENT_TYPE, content_type), + (CONTENT_ENCODING, GZIP), + (CACHE_CONTROL, CACHE_FOR_A_DAY), + (X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + file.contents(), + ) + .into_response(), + None => match ASSETS.get_file(path) { + Some(file) => ( + StatusCode::OK, + [ + (CONTENT_TYPE, content_type), + (CACHE_CONTROL, CACHE_FOR_A_DAY), + (X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + file.contents(), + ) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + }, + } +} diff --git a/templates/src/lib.rs b/templates/src/lib.rs index d9fe86b..0f9f7c6 100644 --- a/templates/src/lib.rs +++ b/templates/src/lib.rs @@ -7,55 +7,9 @@ pub use indieauth::AuthorizationRequestPage; mod login; pub use login::{LoginPage, LogoutPage}; mod mf2; -pub use mf2::{Entry, VCard, Feed, Food, POSTS_PER_PAGE}; - +pub use mf2::{Entry, Feed, Food, VCard, POSTS_PER_PAGE}; pub mod admin; - -pub mod assets { - use axum::response::{IntoResponse, Response}; - use axum::extract::Path; - use axum::http::StatusCode; - use axum::http::header::{CONTENT_TYPE, CONTENT_ENCODING, CACHE_CONTROL, X_CONTENT_TYPE_OPTIONS}; - - const ASSETS: include_dir::Dir<'static> = include_dir::include_dir!("$OUT_DIR/"); - const CACHE_FOR_A_DAY: &str = "max-age=86400"; - const GZIP: &str = "gzip"; - - pub async fn statics( - Path(path): Path<String> - ) -> Response { - let content_type: &'static str = if path.ends_with(".js") { - "application/javascript" - } else if path.ends_with(".css") { - "text/css" - } else if path.ends_with(".html") { - "text/html; charset=\"utf-8\"" - } else { - "application/octet-stream" - }; - - match ASSETS.get_file(path.clone() + ".gz") { - Some(file) => (StatusCode::OK, - [ - (CONTENT_TYPE, content_type), - (CONTENT_ENCODING, GZIP), - (CACHE_CONTROL, CACHE_FOR_A_DAY), - (X_CONTENT_TYPE_OPTIONS, "nosniff") - ], - file.contents()).into_response(), - None => match ASSETS.get_file(path) { - Some(file) => (StatusCode::OK, - [ - (CONTENT_TYPE, content_type), - (CACHE_CONTROL, CACHE_FOR_A_DAY), - (X_CONTENT_TYPE_OPTIONS, "nosniff") - ], - file.contents()).into_response(), - None => StatusCode::NOT_FOUND.into_response() - } - } - } -} +pub mod assets; #[cfg(test)] mod tests { @@ -107,11 +61,11 @@ mod tests { let dt = time::OffsetDateTime::now_utc() .to_offset( time::UtcOffset::from_hms( - rand::distributions::Uniform::new(-11, 12) - .sample(&mut rand::thread_rng()), + rand::distributions::Uniform::new(-11, 12).sample(&mut rand::thread_rng()), if rand::random::<bool>() { 0 } else { 30 }, - 0 - ).unwrap() + 0, + ) + .unwrap(), ) .format(&time::format_description::well_known::Rfc3339) .unwrap(); @@ -218,14 +172,15 @@ mod tests { // potentially with an offset? let offset = item.as_offset().unwrap().data; let date = item.as_date().unwrap().data; - let time = item.as_time().unwrap().data; + let time = item.as_time().unwrap().data; let dt = date.with_time(time).assume_offset(offset); let expected = time::OffsetDateTime::parse( mf2["properties"]["published"][0].as_str().unwrap(), - &time::format_description::well_known::Rfc3339 - ).unwrap(); - + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + assert_eq!(dt, expected); } else { unreachable!() @@ -235,7 +190,8 @@ mod tests { fn check_e_content(mf2: &serde_json::Value, item: &Item) { assert!(item.properties.contains_key("content")); - if let Some(PropertyValue::Fragment(content)) = item.properties.get("content").and_then(|v| v.first()) + if let Some(PropertyValue::Fragment(content)) = + item.properties.get("content").and_then(|v| v.first()) { assert_eq!( content.html, @@ -250,7 +206,11 @@ mod tests { fn test_note() { let mf2 = gen_random_post(&rand::random::<Domain>().to_string(), PostType::Note); - let html = crate::mf2::Entry { post: &mf2, from_feed: false, }.to_string(); + let html = crate::mf2::Entry { + post: &mf2, + from_feed: false, + } + .to_string(); let url: Url = mf2 .pointer("/properties/uid/0") @@ -259,7 +219,12 @@ mod tests { .unwrap(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); - if let Some(item) = parsed.into_iter().find(|i| i.properties.get("url").unwrap().contains(&PropertyValue::Url(url.clone()))) { + if let Some(item) = parsed.into_iter().find(|i| { + i.properties + .get("url") + .unwrap() + .contains(&PropertyValue::Url(url.clone())) + }) { let props = &item.properties; check_e_content(&mf2, &item); @@ -281,7 +246,11 @@ mod tests { #[test] fn test_article() { let mf2 = gen_random_post(&rand::random::<Domain>().to_string(), PostType::Article); - let html = crate::mf2::Entry { post: &mf2, from_feed: false, }.to_string(); + let html = crate::mf2::Entry { + post: &mf2, + from_feed: false, + } + .to_string(); let url: Url = mf2 .pointer("/properties/uid/0") .and_then(|i| i.as_str()) @@ -289,8 +258,12 @@ mod tests { .unwrap(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); - if let Some(item) = parsed.into_iter().find(|i| i.properties.get("url").unwrap().contains(&PropertyValue::Url(url.clone()))) { - + if let Some(item) = parsed.into_iter().find(|i| { + i.properties + .get("url") + .unwrap() + .contains(&PropertyValue::Url(url.clone())) + }) { check_e_content(&mf2, &item); check_dt_published(&mf2, &item); assert!(item.properties.contains_key("uid")); @@ -302,7 +275,9 @@ mod tests { .iter() .any(|i| i == item.properties.get("uid").and_then(|v| v.first()).unwrap())); assert!(item.properties.contains_key("name")); - if let Some(PropertyValue::Plain(name)) = item.properties.get("name").and_then(|v| v.first()) { + if let Some(PropertyValue::Plain(name)) = + item.properties.get("name").and_then(|v| v.first()) + { assert_eq!( name, mf2.pointer("/properties/name/0") @@ -338,7 +313,11 @@ mod tests { .and_then(|i| i.as_str()) .and_then(|u| u.parse().ok()) .unwrap(); - let html = crate::mf2::Entry { post: &mf2, from_feed: false, }.to_string(); + let html = crate::mf2::Entry { + post: &mf2, + from_feed: false, + } + .to_string(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); if let Some(item) = parsed.items.first() { diff --git a/templates/src/mf2.rs b/templates/src/mf2.rs index 787d3ed..aaac80f 100644 --- a/templates/src/mf2.rs +++ b/templates/src/mf2.rs @@ -1,3 +1,7 @@ +#![expect( + clippy::needless_lifetimes, + reason = "bug: Clippy doesn't realize the `markup` crate requires explicit lifetimes due to its idiosyncracies" +)] use ellipse::Ellipse; pub static POSTS_PER_PAGE: usize = 20; diff --git a/templates/src/templates.rs b/templates/src/templates.rs index 9b29fce..5772b4d 100644 --- a/templates/src/templates.rs +++ b/templates/src/templates.rs @@ -1,6 +1,7 @@ +#![allow(clippy::needless_lifetimes)] +use crate::{Feed, VCard}; use http::StatusCode; use kittybox_util::micropub::Channel; -use crate::{Feed, VCard}; markup::define! { Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { diff --git a/tower-watchdog/src/lib.rs b/tower-watchdog/src/lib.rs index 9a5c609..e0be313 100644 --- a/tower-watchdog/src/lib.rs +++ b/tower-watchdog/src/lib.rs @@ -27,22 +27,45 @@ impl<S> tower_layer::Layer<S> for WatchdogLayer { fn layer(&self, inner: S) -> Self::Service { Self::Service { pet: self.pet.clone(), - inner + inner, } } } pub struct WatchdogService<S> { pet: watchdog::Pet, - inner: S + inner: S, } -impl<S: tower_service::Service<Request> + Clone + 'static, Request: std::fmt::Debug + 'static> tower_service::Service<Request> for WatchdogService<S> { +impl<S: tower_service::Service<Request> + Clone + 'static, Request: std::fmt::Debug + 'static> + tower_service::Service<Request> for WatchdogService<S> +{ type Response = S::Response; type Error = S::Error; - type Future = std::pin::Pin<Box<futures::future::Then<std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), tokio::sync::mpsc::error::SendError<()>>> + Send>>, std::pin::Pin<Box<S::Future>>, Box<dyn FnOnce(Result<(), tokio::sync::mpsc::error::SendError<()>>) -> std::pin::Pin<Box<S::Future>>>>>>; + type Future = std::pin::Pin< + Box< + futures::future::Then< + std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result<(), tokio::sync::mpsc::error::SendError<()>>, + > + Send, + >, + >, + std::pin::Pin<Box<S::Future>>, + Box< + dyn FnOnce( + Result<(), tokio::sync::mpsc::error::SendError<()>>, + ) -> std::pin::Pin<Box<S::Future>>, + >, + >, + >, + >; - fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> { + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } @@ -57,7 +80,11 @@ impl<S: tower_service::Service<Request> + Clone + 'static, Request: std::fmt::De std::mem::swap(&mut self.inner, &mut inner); let pet = self.pet.clone(); - Box::pin(pet.pet_owned().boxed().then(Box::new(move |_| Box::pin(inner.call(request))))) + Box::pin( + pet.pet_owned() + .boxed() + .then(Box::new(move |_| Box::pin(inner.call(request)))), + ) } } @@ -84,7 +111,10 @@ mod tests { for i in 100..=1_000 { if i != 1000 { assert!(mock.poll_ready().is_ready()); - let request = Box::pin(tokio::time::sleep(std::time::Duration::from_millis(i)).then(|()| mock.call(()))); + let request = Box::pin( + tokio::time::sleep(std::time::Duration::from_millis(i)) + .then(|()| mock.call(())), + ); tokio::select! { _ = &mut watchdog_future => panic!("Watchdog called earlier than response!"), _ = request => {}, @@ -94,7 +124,10 @@ mod tests { // We use `+ 1` here, because the watchdog behavior is // subject to a data race if a request arrives in the // same tick. - let request = Box::pin(tokio::time::sleep(std::time::Duration::from_millis(i + 1)).then(|()| mock.call(()))); + let request = Box::pin( + tokio::time::sleep(std::time::Duration::from_millis(i + 1)) + .then(|()| mock.call(())), + ); tokio::select! { _ = &mut watchdog_future => { }, diff --git a/util/src/fs.rs b/util/src/fs.rs index 6a7a5b4..ea9dadd 100644 --- a/util/src/fs.rs +++ b/util/src/fs.rs @@ -1,6 +1,6 @@ +use rand::{distributions::Alphanumeric, Rng}; use std::io::{self, Result}; use std::path::{Path, PathBuf}; -use rand::{Rng, distributions::Alphanumeric}; use tokio::fs; /// Create a temporary file named `temp.[a-zA-Z0-9]{length}` in @@ -20,7 +20,7 @@ use tokio::fs; pub async fn mktemp<T, B>(dir: T, basename: B, length: usize) -> Result<(PathBuf, fs::File)> where T: AsRef<Path>, - B: Into<Option<&'static str>> + B: Into<Option<&'static str>>, { let dir = dir.as_ref(); let basename = basename.into().unwrap_or(""); @@ -33,9 +33,9 @@ where if basename.is_empty() { "" } else { "." }, { let string = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(length) - .collect::<Vec<u8>>(); + .sample_iter(&Alphanumeric) + .take(length) + .collect::<Vec<u8>>(); String::from_utf8(string).unwrap() } )); @@ -49,8 +49,8 @@ where Ok(file) => return Ok((filename, file)), Err(err) => match err.kind() { io::ErrorKind::AlreadyExists => continue, - _ => return Err(err) - } + _ => return Err(err), + }, } } } diff --git a/util/src/lib.rs b/util/src/lib.rs index cb5f666..0c5df49 100644 --- a/util/src/lib.rs +++ b/util/src/lib.rs @@ -17,7 +17,7 @@ pub enum MentionType { Bookmark, /// A plain link without MF2 annotations. #[default] - Mention + Mention, } /// Common data-types useful in creating smart authentication systems. @@ -29,7 +29,7 @@ pub mod auth { /// used to recover from a lost passkey. Password, /// Denotes availability of one or more passkeys. - WebAuthn + WebAuthn, } } diff --git a/util/src/micropub.rs b/util/src/micropub.rs index 9d2c525..1f8008b 100644 --- a/util/src/micropub.rs +++ b/util/src/micropub.rs @@ -21,7 +21,7 @@ pub enum QueryType { Category, /// Unsupported query type // TODO: make this take a lifetime parameter for zero-copy deserialization if possible? - Unknown(std::borrow::Cow<'static, str>) + Unknown(std::borrow::Cow<'static, str>), } /// Data structure representing a Micropub channel in the ?q=channels output. @@ -42,7 +42,7 @@ pub struct SyndicationDestination { /// The syndication destination's UID, opaque to the client. pub uid: String, /// A human-friendly name. - pub name: String + pub name: String, } fn default_q_list() -> Vec<QueryType> { @@ -67,7 +67,7 @@ pub struct Config { pub media_endpoint: Option<url::Url>, /// Other unspecified keys, sometimes implementation-defined. #[serde(flatten)] - pub other: HashMap<String, serde_json::Value> + pub other: HashMap<String, serde_json::Value>, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] @@ -145,14 +145,17 @@ impl Error { pub const fn from_static(error: ErrorKind, error_description: &'static str) -> Self { Self { error, - error_description: Some(std::borrow::Cow::Borrowed(error_description)) + error_description: Some(std::borrow::Cow::Borrowed(error_description)), } } } impl From<ErrorKind> for Error { fn from(error: ErrorKind) -> Self { - Self { error, error_description: None } + Self { + error, + error_description: None, + } } } @@ -190,4 +193,3 @@ impl axum_core::response::IntoResponse for Error { )) } } - diff --git a/util/src/queue.rs b/util/src/queue.rs index edbec86..b32fdc5 100644 --- a/util/src/queue.rs +++ b/util/src/queue.rs @@ -1,5 +1,5 @@ -use std::future::Future; use futures_util::Stream; +use std::future::Future; use std::pin::Pin; use uuid::Uuid; @@ -44,7 +44,9 @@ pub trait JobQueue<T: JobItem>: Send + Sync + Sized + Clone + 'static { /// /// Note that one item may be returned several times if it is not /// marked as done. - fn into_stream(self) -> impl Future<Output = Result<JobStream<Self::Job, Self::Error>, Self::Error>> + Send; + fn into_stream( + self, + ) -> impl Future<Output = Result<JobStream<Self::Job, Self::Error>, Self::Error>> + Send; } /// A job description yielded from a job queue. |