diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..bec987f --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d0c97b7..8211efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,10 +63,22 @@ dependencies = [ ] [[package]] -name = "async-compression" -version = "0.4.42" +name = "argon2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -192,25 +204,6 @@ dependencies = [ "http", ] -[[package]] -name = "axum-login" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" -dependencies = [ - "axum", - "form_urlencoded", - "serde", - "subtle", - "thiserror", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions 0.14.0", - "tracing", - "urlencoding", -] - [[package]] name = "axum-macros" version = "0.5.1" @@ -228,6 +221,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.7.0" @@ -237,6 +236,12 @@ dependencies = [ "simd-abstraction", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" @@ -258,6 +263,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -370,12 +384,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "combine" version = "4.6.7" @@ -403,6 +411,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -609,15 +623,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -631,6 +636,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -646,6 +665,17 @@ dependencies = [ "matches", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -685,7 +715,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -695,9 +727,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", - "ctutils", ] [[package]] @@ -774,12 +805,13 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.11.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "home", + "windows-sys 0.48.0", ] [[package]] @@ -825,9 +857,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.12.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", @@ -840,6 +872,12 @@ version = "1.0.7" 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 = "foldhash" version = "0.2.0" @@ -855,6 +893,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "fred" version = "10.1.0" @@ -986,6 +1034,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.33" @@ -1033,9 +1087,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1052,6 +1108,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap 6.2.1", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.5", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "h2" version = "0.4.15" @@ -1086,6 +1165,17 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1094,7 +1184,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1105,11 +1195,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1126,20 +1216,29 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.13.0" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.13.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.11.3", + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -1231,7 +1330,20 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -1240,7 +1352,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1416,7 +1528,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -1479,6 +1591,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1487,10 +1602,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libsqlite3-sys" -version = "0.37.0" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.2", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "pkg-config", "vcpkg", @@ -1507,7 +1640,7 @@ dependencies = [ "const-str", "cssparser", "cssparser-color", - "dashmap", + "dashmap 5.5.3", "data-encoding", "getrandom 0.3.4", "indexmap", @@ -1564,7 +1697,7 @@ checksum = "4543dd90f92818d8a73ba91ab02138bb6751689113e79ddd75130f3f7579883d" dependencies = [ "async-trait", "serde", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1636,12 +1769,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.11.0" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest 0.10.7", ] [[package]] @@ -1665,10 +1798,10 @@ name = "microtld-web" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", + "argon2", "axum", "axum-htmx", - "axum-login", + "base64 0.23.1", "fred", "inventory", "lightningcss", @@ -1678,13 +1811,21 @@ dependencies = [ "reqwest 0.13.4", "rust-embed", "serde", + "sha2 0.11.0", "sqlx", + "subtle", + "time", "tokio", "tower", + "tower-csrf", "tower-http 0.7.0", - "tower-sessions 0.15.0", + "tower-sessions", + "tower-sessions-sqlx-store", + "tower_governor", "tracing", "tracing-subscriber", + "urlencoding", + "uuid", ] [[package]] @@ -1740,6 +1881,18 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1749,12 +1902,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1762,6 +1950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1836,11 +2025,22 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.1.1" @@ -1853,6 +2053,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1940,18 +2149,71 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2023,6 +2285,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quinn" version = "0.11.11" @@ -2037,7 +2314,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2060,7 +2337,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2192,6 +2469,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2235,6 +2521,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.16" @@ -2267,7 +2562,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2296,7 +2591,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -2305,7 +2600,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2380,6 +2675,45 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -2654,13 +2988,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.11.0" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2710,6 +3044,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-abstraction" version = "0.7.1" @@ -2792,10 +3136,29 @@ dependencies = [ ] [[package]] -name = "sqlx" -version = "0.9.0" +name = "spinning_top" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2806,13 +3169,12 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", - "cfg-if", "crc", "crossbeam-queue", "either", @@ -2821,28 +3183,33 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.16.1", + "hashbrown 0.15.5", "hashlink", "indexmap", "log", "memchr", + "once_cell", "percent-encoding", + "rustls", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.19", + "time", "tokio", "tokio-stream", "tracing", "url", + "uuid", + "webpki-roots 0.26.11", ] [[package]] name = "sqlx-macros" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -2853,15 +3220,15 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ - "cfg-if", "dotenvy", "either", "heck", "hex", + "once_cell", "proc-macro2", "quote", "serde", @@ -2872,45 +3239,62 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ + "atoi", + "base64 0.22.1", "bitflags", "byteorder", "bytes", "crc", - "digest 0.11.3", + "digest 0.10.7", "dotenvy", "either", + "futures-channel", "futures-core", + "futures-io", "futures-util", "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", "log", + "md-5", + "memchr", + "once_cell", "percent-encoding", + "rand 0.8.7", + "rsa", "serde", "sha1", - "sha2 0.11.0", + "sha2 0.10.9", + "smallvec", "sqlx-core", - "thiserror", + "stringprep", + "thiserror 2.0.19", + "time", "tracing", + "uuid", + "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -2922,31 +3306,34 @@ dependencies = [ "hex", "hkdf", "hmac", + "home", "itoa", "log", "md-5", "memchr", - "rand 0.10.2", + "once_cell", + "rand 0.8.7", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.19", + "time", "tracing", + "uuid", "whoami", ] [[package]] name = "sqlx-sqlite" -version = "0.9.0" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "flume", - "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -2956,10 +3343,13 @@ dependencies = [ "log", "percent-encoding", "serde", + "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.19", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -3065,13 +3455,33 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3212,6 +3622,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.5", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -3220,9 +3659,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3244,6 +3686,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-csrf" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38be628f9ef990a4714803c31a9ecc45b20e4a11401ec9121a8109de541a4aa" +dependencies = [ + "http", + "thiserror 2.0.19", + "tower", + "tracing", + "url", +] + [[package]] name = "tower-http" version = "0.6.11" @@ -3308,24 +3763,7 @@ dependencies = [ "tower-cookies", "tower-layer", "tower-service", - "tower-sessions-core 0.14.0", - "tracing", -] - -[[package]] -name = "tower-sessions" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core 0.15.0", + "tower-sessions-core", "tower-sessions-memory-store", "tracing", ] @@ -3337,35 +3775,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" dependencies = [ "async-trait", - "base64", + "axum-core", + "base64 0.22.1", "futures", "http", "parking_lot", "rand 0.8.7", "serde", "serde_json", - "thiserror", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "futures", - "http", - "parking_lot", - "rand 0.9.5", - "serde", - "serde_json", - "thiserror", + "thiserror 2.0.19", "time", "tokio", "tracing", @@ -3373,14 +3791,45 @@ dependencies = [ [[package]] name = "tower-sessions-memory-store" -version = "0.15.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" dependencies = [ "async-trait", "time", "tokio", - "tower-sessions-core 0.15.0", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-sqlx-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3" +dependencies = [ + "async-trait", + "rmp-serde", + "sqlx", + "thiserror 1.0.69", + "time", + "tower-sessions-core", +] + +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.19", + "tonic", + "tower", + "tracing", ] [[package]] @@ -3538,7 +3987,9 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3600,6 +4051,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -3684,6 +4141,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -3695,9 +4161,29 @@ dependencies = [ [[package]] name = "whoami" -version = "2.1.2" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" @@ -3708,6 +4194,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -3743,13 +4235,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3761,34 +4262,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3801,24 +4335,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index fbfc0d5..5147b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,24 +11,44 @@ edition = "2024" microtld-web = {path = "web"} microtld-backend = {path = "backend"} -tokio = {version = "1.53.1", features = ["full"]} -lmrc-cloudflare = {version = "0.3.16"} -anyhow = {version = "1.0.104"} -serde = {version = "1.0.229", features = ["derive"]} -derive_more = {version = "2.1.1", default-features = false, features = ["display"]} +tokio = {version = "1.53", features = ["full"]} +lmrc-cloudflare = {version = "0.3"} +anyhow = {version = "1.0"} +serde = {version = "1.0", features = ["derive"]} +derive_more = {version = "2.1", default-features = false, features = ["display"]} -axum = {version = "0.8.9", features = ["macros"]} -axum-htmx = {version = "0.8.1"} -axum-login = {version = "0.18.0"} -tower-sessions = {version = "0.15.0"} -fred = {version = "10.1.0"} -tracing = {version = "0.1.44"} -reqwest = {version = "0.13.4"} -maud = {version = "0.27.0", features = ["axum"]} -sqlx = {version = "0.9.0", features = ["runtime-tokio", "mysql"] } +axum = {version = "0.8", features = ["macros"]} +axum-htmx = {version = "0.8"} +fred = {version = "10.1"} +tracing = {version = "0.1"} +reqwest = {version = "0.13"} +maud = {version = "0.27", features = ["axum"]} async-trait = {version = "0.1.91"} tracing-subscriber = "0.3.23" +base64 = "0.23.1" +sha2 = "0.11" +subtle = "2.6" + +# Auth +tower-sessions = {version = "0.14"} +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } +tower-csrf = "0.1" +argon2 = "0.5" +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "tls-rustls-ring-webpki", + "postgres", + "macros", + "migrate", + "uuid", + "time", + "json", +] } +time = "0.3" +uuid = { version = "1", features = ["v4", "serde"] } +tower_governor = "0.8" + # TODO: DIE :) \ No newline at end of file diff --git a/assets/img/blinkiesCafe-HY.gif b/assets/img/blinkiesCafe-HY.gif new file mode 100644 index 0000000..de98db3 Binary files /dev/null and b/assets/img/blinkiesCafe-HY.gif differ diff --git a/assets/img/blinkiesCafe-eX.gif b/assets/img/blinkiesCafe-eX.gif new file mode 100644 index 0000000..307c2a6 Binary files /dev/null and b/assets/img/blinkiesCafe-eX.gif differ diff --git a/assets/img/blinkiesCafe-gn.gif b/assets/img/blinkiesCafe-gn.gif new file mode 100644 index 0000000..4844531 Binary files /dev/null and b/assets/img/blinkiesCafe-gn.gif differ diff --git a/web/Cargo.toml b/web/Cargo.toml index 392ca04..66d229c 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -11,20 +11,34 @@ anyhow = {workspace = true} axum = {workspace = true } axum-htmx = {workspace = true } -axum-login = {workspace = true } -tower-sessions = {workspace = true } fred = {workspace = true } tracing = {workspace = true } reqwest = {workspace = true } maud = {workspace = true } -sqlx = {workspace = true} tracing-subscriber = {workspace = true, features = ["env-filter"]} serde = {workspace = true} -async-trait = {workspace = true} lightningcss = "1.0.0-alpha.72" inventory = "0.3.24" -tower = "0.5" tower-http = { version = "0.7.0", features = ["trace", "compression-gzip"] } -rust-embed = "8" -mime_guess = "2" \ No newline at end of file +base64 = {workspace = true} +sha2 = {workspace = true} +subtle = {workspace = true} + +#Auth +tower-sessions = {workspace = true} +tower-sessions-sqlx-store = {workspace = true} +tower-csrf = {workspace = true} +argon2 = {workspace = true} +sqlx = {workspace = true} +time = {workspace = true} +uuid = {workspace = true} +tower_governor = {workspace = true} +tower = "0.5.3" + +rust-embed = {version = "8.12.0", features = ["debug-embed"]} +mime_guess = "2" +urlencoding = "2.1.3" + +[profile.release] +debug = true \ No newline at end of file diff --git a/web/assets/img/blinkiesCafe-HY.gif b/web/assets/img/blinkiesCafe-HY.gif new file mode 100644 index 0000000..de98db3 Binary files /dev/null and b/web/assets/img/blinkiesCafe-HY.gif differ diff --git a/web/assets/img/blinkiesCafe-eX.gif b/web/assets/img/blinkiesCafe-eX.gif new file mode 100644 index 0000000..307c2a6 Binary files /dev/null and b/web/assets/img/blinkiesCafe-eX.gif differ diff --git a/web/assets/img/blinkiesCafe-gn.gif b/web/assets/img/blinkiesCafe-gn.gif new file mode 100644 index 0000000..4844531 Binary files /dev/null and b/web/assets/img/blinkiesCafe-gn.gif differ diff --git a/web/assets/img/github-mark.svg b/web/assets/img/github-mark.svg new file mode 100644 index 0000000..17d1561 --- /dev/null +++ b/web/assets/img/github-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/build.rs b/web/build.rs new file mode 100644 index 0000000..d506869 --- /dev/null +++ b/web/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/web/migrations/20260820090931_create_users.down.sql b/web/migrations/20260820090931_create_users.down.sql new file mode 100644 index 0000000..f67aede --- /dev/null +++ b/web/migrations/20260820090931_create_users.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE users; \ No newline at end of file diff --git a/web/migrations/20260820090931_create_users.up.sql b/web/migrations/20260820090931_create_users.up.sql new file mode 100644 index 0000000..f458e86 --- /dev/null +++ b/web/migrations/20260820090931_create_users.up.sql @@ -0,0 +1,11 @@ +-- Add up migration script here +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + email_confirmed_at TIMESTAMPTZ, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + two_factor_secret TEXT +); \ No newline at end of file diff --git a/web/migrations/20260820102145_user_confirmations.down.sql b/web/migrations/20260820102145_user_confirmations.down.sql new file mode 100644 index 0000000..64c4249 --- /dev/null +++ b/web/migrations/20260820102145_user_confirmations.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP INDEX idx_confirmations_user_id; +DROP TABLE confirmations; \ No newline at end of file diff --git a/web/migrations/20260820102145_user_confirmations.up.sql b/web/migrations/20260820102145_user_confirmations.up.sql new file mode 100644 index 0000000..73c7bba --- /dev/null +++ b/web/migrations/20260820102145_user_confirmations.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here +CREATE TABLE confirmations ( + identifier BYTEA PRIMARY KEY, + verifier_hash BYTEA NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action_type TEXT NOT NULL, + details JSONB, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_confirmations_user_id ON confirmations(user_id); \ No newline at end of file diff --git a/web/src/web/content/partials/footer.rs b/web/src/web/content/partials/footer.rs index 2a884f5..c3b38fa 100644 --- a/web/src/web/content/partials/footer.rs +++ b/web/src/web/content/partials/footer.rs @@ -1,22 +1,87 @@ +use crate::web::content::style::CssFragment; use super::*; +inventory::submit! { + CssFragment(r#" + footer { + display: flex; + flex-direction: column; + align-items: end; + justify-content: center; + gap: 1rem; + padding: 1rem; + background: var(--footer-bg); + + > .upper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 1rem; + width: 100%; + + > .accessibility { + display: flex; + .skip-link { + position: absolute; + top: -10000px; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; + } + + .skip-link:focus { + position: fixed; + top: 10px; + left: 10px; + width: auto; + height: auto; + padding: 10px 20px; + background-color: #fff; + color: #000; + border: 2px solid #000; + z-index: 9999; + } + } + } + + > .lower { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 1rem; + width: 100%; + } + } + "# + ) +} + pub fn footer() -> Markup { html! { footer { - div class="accessibility" { - a href="#main-content" { "Skip to main content" } - a href="#footer" { "Skip to footer" } - a href="#navigation" { "Skip to navigation" } + div class="upper" { + div class="decoration" { + img src="/assets/img/blinkiesCafe-eX.gif"; + img src="/assets/img/blinkiesCafe-gn.gif"; + img src="/assets/img/blinkiesCafe-HY.gif"; + } + div class="accessibility" { + nav aria-label="Skip links" { + a.skip-link href="#main-content" tabindex="0" { "Skip to main content" } + a.skip-link href="#footer" tabindex="1" { "Skip to footer" } + a.skip-link href="#navigation" tabindex="2" { "Skip to navigation" } + } + } } - div class="decoration" { - img src="/static/img/banner-begay.gif"; - img src="/static/img/banner-docrime.gif"; - img src="/static/img/banner-hate.gif"; - } - div class="notice" { - span { - "Cookies are used to store your session." - "We do not use them to track you." + div class="lower" { + div class="notice" { + span { + "Cookies are used to store your session." + "We do not use them to track you." + } } } } diff --git a/web/src/web/content/partials/header.rs b/web/src/web/content/partials/header.rs index f624de3..bf2e709 100644 --- a/web/src/web/content/partials/header.rs +++ b/web/src/web/content/partials/header.rs @@ -1,23 +1,69 @@ +use crate::web::content::style::CssFragment; use super::*; +inventory::submit! { + CssFragment(r#" + .nav-bar { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.5rem; + background: var(--nav-bg); + + > .spacer { + flex-grow: 1; + } + + > .link { + color: var(--nav-link); + text-decoration: none; + + border: none; + padding: 0; + background: none; + + cursor: pointer; + font: inherit; + + > img { + max-height: 1.5rem; + } + } + + > .link.__active { + font-weight: 600; + color: var(--nav-link-active); + } + + > .title { + color: var(--nav-link); + + font-size: 1.5rem; + font-weight: 600; + + color: var(--nav-link); + } + } + "#) +} + pub fn header(is_auth: bool) -> Markup { html! { - nav class="navigation" { - ul { - li { a href="/" { "Home" } } - li { a href="/regdomain" { "Register a subdomain" }} - li { a href="/status" { "Status" }} - div class="spacer" {} - @if is_auth { - li { a href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#popup" hx-swap="innerHTML" { "Logout" }} - } @else { - li { a href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#popup" hx-swap="innerHTML" { "Login" }} - li { a href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#popup" hx-swap="innerHTML" { "Register" }} - } - li { a href="https://github.com/microtld/microtld" { - img src="/static/img/github-mark.svg"; - }} + nav.nav-bar { + a.title href="/" { "MicroTLD" } + + a.link.__active href="/" { "Home" } + a.link href="/regdomain" { "Register a subdomain" } + a.link href="/status" { "Status" } + div class="spacer" {} + @if is_auth { + button.link href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Logout" } + } @else { + button.link href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Login" } + button.link href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Register" } } + a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";} } } } \ No newline at end of file diff --git a/web/src/web/content/partials/page.rs b/web/src/web/content/partials/page.rs index ff8b928..e2a4d2d 100644 --- a/web/src/web/content/partials/page.rs +++ b/web/src/web/content/partials/page.rs @@ -2,8 +2,97 @@ use maud::DOCTYPE; use crate::web::content::partials::footer::footer; use crate::web::content::partials::head::head; use crate::web::content::partials::header::header; +use crate::web::content::style::CssFragment; use super::*; +inventory::submit! { + CssFragment(r#" + .main-content { + padding: 1rem; + display: flex; + flex-direction: column; + flex-grow: 1; + } + + /* Style the built-in backdrop provided by the Popover API */ + #modal::backdrop { + background-color: rgba(0, 0, 0, 0.5); + } + + #modal { + /* Underlay covers entire screen. */ + top:0px; + bottom: 0px; + left:0px; + right:0px; + + background-color: color-mix(in srgb, var(--bg) 50%, transparent); + border: 1px solid var(--border); + + z-index: 1000; + color: var(--text); + + justify-content: center; + flex-direction:column; + align-items:center; + + > #modal-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + > form { + display: flex; + flex-direction: column; + + > label { + padding-top: 1rem; + } + + > button { + margin-top: 1rem; + } + } + } + + > .modal-close { + z-index: 1001; + background: rgba(200, 20, 20, 0.8); + + color: var(--text); + font-weight: 600; + + border: none; + + margin-top: 1rem; + + cursor: pointer; + font: inherit; + } + + > .modal-close:hover { + background: rgba(200, 20, 20, 0.6); + + transition: background 0.2s ease-in-out; + } + + padding: 1rem 3rem; + } + + #modal:popover-open { + display:flex; + } + "# + ) +} + +pub fn show_error(message: &str) -> Markup { + html! { + h1 { (message) } + } +} + pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -> Markup { html! { (DOCTYPE) @@ -11,9 +100,14 @@ pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) - (head(page_title, page_slug)) (header(is_auth)) body { - div class="main-content" { + div class="main-content" tabindex="-1" { (content) } + + div popover id="modal" { + div id="modal-content" {} + button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" } + } } (footer()) } diff --git a/web/src/web/content/style/css/base.css b/web/src/web/content/style/css/base.css index ab4df6a..38f2fc9 100644 --- a/web/src/web/content/style/css/base.css +++ b/web/src/web/content/style/css/base.css @@ -1,3 +1,35 @@ -html { - background-color: #0e0e0e; +:root { + --text: #f3f3f3; + --text-muted: #cecece; + --bg: #0e0e0e; + --border: #5f2f5f; + --accent: #c11292; + --nav-bg: #1e1e1e; + --nav-link: #f3f3f3; + --nav-link-active: #ce9ece; +} + +html { + height: 100%; + width: 100%; + scroll-behavior: smooth; +} + +body { + font-family: 'Roboto', system-ui, -apple-system, sans-serif; + color: var(--text); + background: var(--bg); + margin: 0; + line-height: 1.6; + + background-color: #0e0e0e; + font-size: 16px; + padding: 0; + overflow-x: hidden; + overflow-y: auto; + height: 100vh; + width: 100vw; + + display: flex; + flex-direction: column; } diff --git a/web/src/web/mod.rs b/web/src/web/mod.rs index 3d74f9e..8331c9a 100644 --- a/web/src/web/mod.rs +++ b/web/src/web/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod content; pub(crate) mod htmx; -pub(crate) mod service; \ No newline at end of file +pub(crate) mod service; diff --git a/web/src/web/service/auth/confirmation.rs b/web/src/web/service/auth/confirmation.rs new file mode 100644 index 0000000..b743558 --- /dev/null +++ b/web/src/web/service/auth/confirmation.rs @@ -0,0 +1,106 @@ +use argon2::password_hash::rand_core::{OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use sqlx::{Error, PgPool}; +use sqlx::types::Json; +use time::{OffsetDateTime, Duration}; +use uuid::Uuid; + + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Confirmation { + identifier: [u8; 16], + + #[sqlx(skip)] + verifier: Option<[u8; 16]>, + + pub verifier_hash: [u8; 32], + pub user_id: Uuid, + pub action_type: String, + details: Option>, + expires_at: OffsetDateTime, + created_at: OffsetDateTime, +} + +impl Confirmation { + pub fn new(user_id: Uuid, action_type: String, expiry_duration: Duration) -> Confirmation { + let mut rng = OsRng::default(); + let mut identifier = [0; 16]; + rng.fill_bytes(&mut identifier); + let mut verifier = [0; 16]; + rng.fill_bytes(&mut verifier); + + let mut hasher = Sha256::default(); + Digest::update(&mut hasher, &verifier); + let verifier_hash = hasher.finalize().0; + + let now = OffsetDateTime::now_utc(); + + Confirmation { + identifier, + verifier: Some(verifier), + verifier_hash, + user_id, + action_type, + details: None, + expires_at: now + expiry_duration, + created_at: now, + } + } + + /// for use in http://localhost:3000/auth/confirm?token= + pub fn get_url_token(&self) -> anyhow::Result { + Ok(base64::encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat())) + } + + pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec, Vec)> { + let decoded_token = base64::decode(token)?; + let (identifier, verifier) = decoded_token.split_at(16); + Ok((identifier.to_vec(), verifier.to_vec())) + } + + pub async fn submit(self, db: &PgPool) -> anyhow::Result { + let result = sqlx::query_as::<_, Confirmation>( + r#" + INSERT INTO confirmations (identifier, verifier_hash, user_id, action_type, details, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(&self.identifier) + .bind(&self.verifier_hash) + .bind(&self.user_id) + .bind(&self.action_type) + .bind(&self.details) + .bind(&self.expires_at) + .bind(&self.created_at) + .fetch_optional(db) + .await; + + match result { + Ok(c) => { + match c { + None => { + Err(anyhow::anyhow!("Confirmation didn't make it to the database.")) + } + Some(c) => { + Ok(c) + } + } + }, + Err(Error::Database(db_err)) if db_err.is_unique_violation() => { + Err(anyhow::anyhow!("Confirmation already exists")) + } + Err(e) => { + Err(anyhow::anyhow!("Database error: {}", e)) + } + } + } + + pub fn is_expired(&self) -> bool { + self.expires_at < OffsetDateTime::now_utc() + } + + pub fn is_valid(&self, verifier: &[u8; 16]) -> bool { + self.verifier.as_ref().map_or(false, |v| v == verifier) + } +} \ No newline at end of file diff --git a/web/src/web/service/auth/email.rs b/web/src/web/service/auth/email.rs new file mode 100644 index 0000000..858ef07 --- /dev/null +++ b/web/src/web/service/auth/email.rs @@ -0,0 +1,20 @@ +pub struct EmailHandler { + +} + +impl EmailHandler { + pub fn send_confirmation_email(&self, email: String, confirmation_token: String) { + } + + pub fn send_password_reset_email(&self, email: String, reset_token: String) { + } + + pub fn send_attempted_registration_email(&self, email: String) { + } + + pub fn send_attempted_login_email(&self, email: String) { + } + + pub fn send_password_changed_email(&self, email: String) { + } +} \ No newline at end of file diff --git a/web/src/web/service/auth/hash.rs b/web/src/web/service/auth/hash.rs new file mode 100644 index 0000000..cbabe8f --- /dev/null +++ b/web/src/web/service/auth/hash.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; +use argon2::{ + password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, + }, + Algorithm, Argon2, Params, Version, +}; +use tokio::task; +// fn build_hasher() -> Argon2<'static> { +// let params = Params::new( +// 64 * 1024, // 64 MiB memory cost +// 2, // 2 iterations +// 1, // 1 degree of parallelism +// None, // default output length (32 bytes) +// ) +// .expect("valid argon2 params"); +// +// Argon2::new(Algorithm::Argon2id, Version::V0x13, params) +// } + +fn build_hasher_with_pepper(pepper: &[u8]) -> Argon2<'_> { + let params = Params::new(64 * 1024, 2, 1, None).expect("valid argon2 params"); + Argon2::new_with_secret(pepper, Algorithm::Argon2id, Version::V0x13, params) + .expect("valid argon2 secret") +} + + + +pub fn hash_password(password: &str, pepper: &[u8]) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = build_hasher_with_pepper(pepper).hash_password(password.as_bytes(), &salt)?; + Ok(hash.to_string()) +} + +pub fn verify_password( + password: &str, + stored_hash: &str, + pepper: &[u8] +) -> Result<(), argon2::password_hash::Error> { + let parsed = PasswordHash::new(stored_hash)?; + build_hasher_with_pepper(pepper).verify_password(password.as_bytes(), &parsed) +} + +pub async fn hash_password_async(password: String, pepper: Arc<[u8]>) -> Result { + task::spawn_blocking(move || hash_password(&password, pepper.as_ref())) + .await? + .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e)) +} + +pub async fn verify_password_async( + password: String, + stored_hash: String, + pepper: Arc<[u8]> +) -> Result<(), anyhow::Error> { + task::spawn_blocking(move || verify_password(&password, &stored_hash, pepper.as_ref())) + .await? + .map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e)) +} + diff --git a/web/src/web/service/auth/mod.rs b/web/src/web/service/auth/mod.rs index 2cb2bd9..4ae34f7 100644 --- a/web/src/web/service/auth/mod.rs +++ b/web/src/web/service/auth/mod.rs @@ -1,32 +1,5 @@ -use std::fmt::{Display, Formatter}; -use axum_login::{AuthnBackend, UserId}; -use crate::web::service::WebService; - -mod user; - -impl AuthnBackend for WebService { - type User = user::User; - type Credentials = user::Credentials; - type Error = AuthError; - - async fn authenticate(&self, creds: Self::Credentials) -> Result, Self::Error> { - todo!() - } - async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { - todo!() - } -} - -#[derive(Debug)] -pub struct AuthError { - username: String, - reason: String, -} - -impl Display for AuthError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "AuthError for user {}: {}", self.username, self.reason) - } -} - -impl std::error::Error for AuthError {} \ No newline at end of file +pub mod user; +pub mod hash; +pub mod session; +pub mod confirmation; +pub mod email; \ No newline at end of file diff --git a/web/src/web/service/auth/session.rs b/web/src/web/service/auth/session.rs new file mode 100644 index 0000000..c8d1eef --- /dev/null +++ b/web/src/web/service/auth/session.rs @@ -0,0 +1,21 @@ +use sqlx::{PgPool}; +use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::{PostgresStore}; +use time::Duration; + +pub async fn session_layer(pool: PgPool) -> SessionManagerLayer { + let store = PostgresStore::new(pool); + store.migrate().await.expect("session table migration failed"); + + // Clean up expired sessions every 60 seconds + tokio::task::spawn( + store + .clone() + .continuously_delete_expired(tokio::time::Duration::from_secs(60)), + ); + + SessionManagerLayer::new(store) + .with_secure(false)//todo: set to true + .with_expiry(Expiry::OnInactivity(Duration::hours(24))) +} + diff --git a/web/src/web/service/auth/user.rs b/web/src/web/service/auth/user.rs index 8d01e98..ee287a9 100644 --- a/web/src/web/service/auth/user.rs +++ b/web/src/web/service/auth/user.rs @@ -1,5 +1,9 @@ -use axum_login::AuthUser; use serde::{Deserialize, Serialize}; +use sqlx::{Error, PgPool}; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; +use crate::web::service::auth::confirmation::Confirmation; +use crate::web::service::auth::email::EmailHandler; #[derive(Clone, Deserialize)] pub struct Credentials { @@ -7,24 +11,63 @@ pub struct Credentials { pub password: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct User { - pub id: u64, + pub id: Uuid, pub username: String, pub email: String, + pub email_confirmed_at: Option, pub password_hash: String, - pub two_factor_enabled: bool, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, pub two_factor_secret: Option, } -impl AuthUser for User { - type Id = u64; +impl User { + pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result { + // ON CONFLICT DO NOTHING prevents errors on duplicate email + // without revealing whether the email already exists + let result = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (username, email, password_hash) + VALUES ($1, $2, $3) + ON CONFLICT (email) DO NOTHING + RETURNING * + "#, + ) + .bind(&username) + .bind(&email) + .bind(&password_hash) + .fetch_optional(db) + .await; - fn id(&self) -> u64 { - self.id - } - - fn session_auth_hash(&self) -> &[u8] { - self.password_hash.as_bytes() + match result { + Ok(u) => { + if let Some(u) = u { + let email_handler = EmailHandler{}; + let confirmation = Confirmation::new(u.id, "email_confirmation".into(), Duration::hours(24)); + let confirmation_token = confirmation.get_url_token().map_err(|e|RegistrationError::AnyhowError(e))?; + println!("http://localhost:3000/auth/confirm?token={}", urlencoding::encode(&confirmation_token)); + email_handler.send_confirmation_email(email, confirmation_token); + confirmation.submit(db).await.map_err(|e|RegistrationError::AnyhowError(e))?; + Ok(u) + } else { + Err(RegistrationError::EmailExists) + } + } + Err(Error::Database(db_err)) if db_err.is_unique_violation() => { + Err(RegistrationError::UsernameExists) + } + Err(e) => { + Err(RegistrationError::DatabaseError(e)) + } + } } } + +pub enum RegistrationError { + UsernameExists, + EmailExists, + DatabaseError(Error), + AnyhowError(anyhow::Error), +} \ No newline at end of file diff --git a/web/src/web/service/extractors/authuser.rs b/web/src/web/service/extractors/authuser.rs new file mode 100644 index 0000000..3589b39 --- /dev/null +++ b/web/src/web/service/extractors/authuser.rs @@ -0,0 +1,40 @@ +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use sqlx::PgPool; +use tower_sessions::Session; +use uuid::Uuid; +use crate::web::service::AppState; +use crate::web::service::auth::user::User; + +pub struct AuthUser(pub User); + +impl FromRequestParts for AuthUser { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let user_id: Uuid = session + .get("user_id") + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(AuthUser(user)) + } +} + diff --git a/web/src/web/service/extractors/mod.rs b/web/src/web/service/extractors/mod.rs index 40e3e84..95ce1a7 100644 --- a/web/src/web/service/extractors/mod.rs +++ b/web/src/web/service/extractors/mod.rs @@ -1 +1,2 @@ -pub mod dns; \ No newline at end of file +pub mod dns; +mod authuser; \ No newline at end of file diff --git a/web/src/web/service/mod.rs b/web/src/web/service/mod.rs index 35d86e4..ae2676c 100644 --- a/web/src/web/service/mod.rs +++ b/web/src/web/service/mod.rs @@ -2,7 +2,9 @@ pub mod auth; pub mod routes; pub mod extractors; -use sqlx::Pool; +use std::sync::Arc; +use sqlx::{PgPool, Pool}; +use sqlx::postgres::PgPoolOptions; use tokio::signal; use tracing_subscriber::EnvFilter; use microtld_backend::MicroTld; @@ -15,7 +17,7 @@ pub struct WebService { #[derive(Debug, Clone)] pub struct AppState { - db: Pool, + db: PgPool, config: AppConfig, micro_tld: MicroTld } @@ -24,6 +26,7 @@ pub struct AppState { pub struct AppConfig { database_url: String, listen_url: String, + pepper: Arc<[u8]> } impl WebService { @@ -31,12 +34,22 @@ impl WebService { let config = AppConfig { database_url, listen_url, + pepper: Arc::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]), }; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&config.database_url) + .await?; + + sqlx::migrate!() + .run(&pool) + .await?; + let state = AppState { - db: Pool::connect(&config.database_url).await?, + db: pool, config, micro_tld, }; - let router = routes::apply_routes(axum::Router::new()).with_state(state.clone()); + let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone()); Ok(WebService {router, state}) } diff --git a/web/src/web/service/routes/auth/confirm.rs b/web/src/web/service/routes/auth/confirm.rs new file mode 100644 index 0000000..12342c4 --- /dev/null +++ b/web/src/web/service/routes/auth/confirm.rs @@ -0,0 +1,95 @@ +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use serde::Deserialize; +use sha2::Digest; +use subtle::ConstantTimeEq; +use time::OffsetDateTime; +use tower_sessions::Session; +use crate::web::service::AppState; +use crate::web::service::auth::confirmation::Confirmation; +use crate::web::service::auth::user::User; +use crate::web::service::routes::Route; + +#[derive(Deserialize, Default)] +pub(crate) struct Confirm { + token: String +} + +impl Route<'_, Query> for Confirm { + const PATH: &'static str = "/confirm"; + + async fn process(session: Session, state: State, query: Query) -> impl IntoResponse { + if session.is_empty().await { + return "Not logged in".into_response(); + } + + let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap(); + let verifier_hash = sha2::Sha256::digest(&verifier).0; + + let confirmation: Option = sqlx::query_as("SELECT * FROM confirmations WHERE identifier = $1") + .bind(&identifier) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + match confirmation { + Some(confirmation) => { + if confirmation.action_type != "email_confirmation" { + return "Invalid confirmation type".into_response(); + } + + if confirmation.is_expired() { + return "Confirmation expired".into_response(); + } + + match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() { + 1 => { + let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(confirmation.user_id) + .fetch_optional(&state.db) + .await.unwrap_or(None); + + match user { + Some(mut user) => { + user.email_confirmed_at = Some(OffsetDateTime::now_utc()); + match sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2") + .bind(&user.email_confirmed_at) + .bind(&user.id) + .execute(&state.db) + .await { + Ok(_) => { + match sqlx::query("DELETE FROM confirmations WHERE identifier = $1") + .bind(&identifier) + .execute(&state.db) + .await { + Ok(_) => { + "Email confirmed".into_response() + } + Err(e) => { + "Error confirming email".into_response() + } + } + }, + Err(e) => { + "Error confirming email".into_response() + } + } + + + }, + None => { + "User not found".into_response() + } + } + } + _ => { + "Invalid token".into_response() + } + } + } + None => { + "Confirmation not found".into_response() + } + } + } +} diff --git a/web/src/web/service/routes/auth/login.rs b/web/src/web/service/routes/auth/login.rs index def68fb..b34e59f 100644 --- a/web/src/web/service/routes/auth/login.rs +++ b/web/src/web/service/routes/auth/login.rs @@ -1,9 +1,13 @@ use axum::extract::{Form, State}; -use axum::response::IntoResponse; -use reqwest::StatusCode; +use axum::http::{HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect}; use serde::Deserialize; +use tower_sessions::Session; +use crate::web::content::partials::page::show_error; use crate::web::service::AppState; +use crate::web::service::auth::user::User; use crate::web::service::routes::Route; +use crate::web::service::auth::hash::{hash_password_async, verify_password_async}; #[derive(Deserialize, Default)] pub(crate) struct Login { @@ -14,8 +18,41 @@ pub(crate) struct Login { impl Route<'_, Form> for Login { const PATH: &'static str = "/login"; - async fn get_content(state: State, Form(login_data): Form) -> impl IntoResponse { - todo!("implement login"); - StatusCode::OK + async fn process(session: Session, state: State, Form(form): Form) -> impl IntoResponse { + let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = $1") + .bind(&form.username) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + let Some(user) = user else { + println!("User not found"); + // Run a dummy hash to prevent timing-based user enumeration + let _ = hash_password_async("dummy-password".to_string(), state.config.pepper.clone()).await; + return show_error("Invalid email or password").into_response(); + }; + + if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone()) + .await + .is_err() + { + println!("Password verification failed"); + return show_error("Invalid email or password").into_response(); + } + + // Prevent session fixation: generate a new session ID, preserving data + session.cycle_id().await.expect("failed to cycle session ID"); + + // Store user identity in the session + session + .insert("user_id", user.id) + .await + .expect("failed to insert session data"); + + // Validate redirect target if using a ?next= parameter. + // Only allow relative paths. Reject absolute URLs to prevent open redirects. + let mut response = StatusCode::OK.into_response(); + response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap()); + response } } \ No newline at end of file diff --git a/web/src/web/service/routes/auth/loginprompt.rs b/web/src/web/service/routes/auth/loginprompt.rs index 1238ab4..804b248 100644 --- a/web/src/web/service/routes/auth/loginprompt.rs +++ b/web/src/web/service/routes/auth/loginprompt.rs @@ -1,6 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use maud::html; +use tower_sessions::Session; use crate::web::service::AppState; use crate::web::service::routes::Route; @@ -9,12 +10,19 @@ pub(crate) struct LoginPrompt; impl Route<'_, ()> for LoginPrompt { const PATH: &'static str = "/loginprompt"; - async fn get_content(_state: State, _query: ()) -> impl IntoResponse { + async fn process(_session: Session, _state: State, _query: ()) -> impl IntoResponse { html! { - form { + h1 { "Login" } + form hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" { + label for="username" { "Username" } input type="text" name="username" placeholder="Username" {} - input type="password" name="password" placeholder="" {} - input type="submit" value="Login" {} + + label for="password" { "Password" } + input type="password" name="password" id="password" + required minlength="10" maxlength="128" + autocomplete="new-password"; + + button type="submit" { "Login" } } } } diff --git a/web/src/web/service/routes/auth/logout.rs b/web/src/web/service/routes/auth/logout.rs index c433116..3301379 100644 --- a/web/src/web/service/routes/auth/logout.rs +++ b/web/src/web/service/routes/auth/logout.rs @@ -1,21 +1,20 @@ use axum::extract::{Form, State}; -use axum::response::IntoResponse; -use reqwest::StatusCode; +use axum::http::{HeaderValue, Response, StatusCode}; +use axum::response::{IntoResponse, Redirect}; use serde::Deserialize; +use tower_sessions::Session; use crate::web::service::AppState; use crate::web::service::routes::Route; -#[derive(Deserialize, Default)] -pub(crate) struct Logout { - username: String, - session_id: String -} +pub(crate) struct Logout; -impl Route<'_, Form> for Logout { +impl Route<'_, ()> for Logout { const PATH: &'static str = "/logout"; - async fn get_content(state: State, Form(query): Form) -> impl IntoResponse { - todo!("implement logout"); - StatusCode::OK + async fn process(session: Session, state: State, _: ()) -> impl IntoResponse { + session.flush().await.expect("failed to flush session"); + let mut response = StatusCode::OK.into_response(); + response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap()); + response } } \ No newline at end of file diff --git a/web/src/web/service/routes/auth/logoutprompt.rs b/web/src/web/service/routes/auth/logoutprompt.rs index 8522472..cbafaea 100644 --- a/web/src/web/service/routes/auth/logoutprompt.rs +++ b/web/src/web/service/routes/auth/logoutprompt.rs @@ -1,6 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use maud::html; +use tower_sessions::Session; use crate::web::service::AppState; use crate::web::service::routes::Route; @@ -9,11 +10,12 @@ pub(crate) struct LogoutPrompt; impl Route<'_, ()> for LogoutPrompt { const PATH: &'static str = "/logoutprompt"; - async fn get_content(_state: State, _query: ()) -> impl IntoResponse { + async fn process(_session: Session, _state: State, _query: ()) -> impl IntoResponse { html! { - p { "Are you sure you want to logout?" } - form { - input type="submit" value="Logout" {} + h1 { "Logout" } + form hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" { + p { "Are you sure you want to logout?" } + button type="submit" { "Logout" } } } } diff --git a/web/src/web/service/routes/auth/mod.rs b/web/src/web/service/routes/auth/mod.rs index 273e95c..50c7b67 100644 --- a/web/src/web/service/routes/auth/mod.rs +++ b/web/src/web/service/routes/auth/mod.rs @@ -4,6 +4,7 @@ pub mod logoutprompt; pub mod login; pub mod register; pub mod logout; +mod confirm; pub(crate) const PATH: &'static str = "/auth"; @@ -12,4 +13,5 @@ pub(crate) use registerprompt::RegisterPrompt; pub(crate) use logoutprompt::LogoutPrompt; pub(crate) use login::Login; pub(crate) use register::Register; -pub(crate) use logout::Logout; \ No newline at end of file +pub(crate) use logout::Logout; +pub(crate) use confirm::Confirm; \ No newline at end of file diff --git a/web/src/web/service/routes/auth/register.rs b/web/src/web/service/routes/auth/register.rs index dc859e3..3201f35 100644 --- a/web/src/web/service/routes/auth/register.rs +++ b/web/src/web/service/routes/auth/register.rs @@ -1,23 +1,75 @@ use axum::extract::{Form, State}; use axum::response::IntoResponse; -use reqwest::StatusCode; +use maud::html; use serde::Deserialize; +use tower_sessions::Session; +use crate::web::content::partials::page::show_error; use crate::web::service::AppState; use crate::web::service::routes::Route; +use crate::web::service::auth::hash::hash_password_async; +use crate::web::service::auth::user::{RegistrationError, User}; -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Debug)] pub(crate) struct Register { username: String, email: String, password: String, - password_confirm: String, + password_confirmation: String, } impl Route<'_, Form> for Register { const PATH: &'static str = "/register"; - async fn get_content(state: State, Form(query): Form) -> impl IntoResponse { - todo!("implement register"); - StatusCode::OK + async fn process(_session: Session, State(state): State, Form(form): Form) -> impl IntoResponse { + if form.password != form.password_confirmation { + return show_error("Passwords do not match").into_response(); + } + if form.password.len() < 10 || form.password.len() > 128 { + return show_error("Password must be 10 to 128 characters").into_response(); + } + + let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await { + Ok(hash) => hash, + Err(_) => return show_error("Registration failed").into_response(), + }; + + match User::register(form.username, form.email, password_hash, &state.db).await { + Ok(_) => { + html! { + h1 { "Check your email" } + p { "If this email can be used for an account, you will receive further instructions." } + }.into_response() + }, + Err(error) => { + match error { + RegistrationError::UsernameExists => { + html! { + h1 { "Username taken" } + p { "This username has already been taken. Please choose another." } + }.into_response() + }, + RegistrationError::EmailExists => { + html! { + h1 { "Check your email" } + p { "If this email can be used for an account, you will receive further instructions." } + }.into_response() + } + RegistrationError::DatabaseError(e) => { + eprintln!("Database error: {}", e); + html! { + h1 { "An error occurred" } + p { "Please try again later." } + }.into_response() + } + RegistrationError::AnyhowError(e) => { + eprintln!("Anyhow error: {}", e); + html! { + h1 { "An error occurred" } + p { "Please try again later." } + }.into_response() + } + } + } + } } } \ No newline at end of file diff --git a/web/src/web/service/routes/auth/registerprompt.rs b/web/src/web/service/routes/auth/registerprompt.rs index 854e74a..05010b3 100644 --- a/web/src/web/service/routes/auth/registerprompt.rs +++ b/web/src/web/service/routes/auth/registerprompt.rs @@ -1,6 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use maud::html; +use tower_sessions::Session; use crate::web::service::AppState; use crate::web::service::routes::Route; @@ -9,14 +10,27 @@ pub(crate) struct RegisterPrompt; impl Route<'_, ()> for RegisterPrompt { const PATH: &'static str = "/registerprompt"; - async fn get_content(_state: State, _query: ()) -> impl IntoResponse { + async fn process(_session: Session, _state: State, _query: ()) -> impl IntoResponse { html! { - form { + h1 { "Create an account" } + form hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" { + label for="username" { "Username" } input type="text" name="username" placeholder="Username" {} - input type="email" name="email" placeholder="Email" {} - input type="password" name="password" placeholder="" {} - input type="password" name="password_confirm" placeholder="Confirm password" {} - input type="submit" value="Register" {} + + label for="email" { "Email" } + input type="email" name="email" id="email" required; + + label for="password" { "Password" } + input type="password" name="password" id="password" + required minlength="10" maxlength="128" + autocomplete="new-password"; + + label for="password_confirmation" { "Confirm password" } + input type="password" name="password_confirmation" + id="password_confirmation" required + autocomplete="new-password"; + + button type="submit" { "Register" } } } } diff --git a/web/src/web/service/routes/mod.rs b/web/src/web/service/routes/mod.rs index e9ccec3..c3bab35 100644 --- a/web/src/web/service/routes/mod.rs +++ b/web/src/web/service/routes/mod.rs @@ -1,47 +1,99 @@ mod root; mod auth; -use axum::extract::State; -use axum::http::header; -use axum::response::IntoResponse; -use axum::Router; +use std::sync::Arc; +use std::time::Instant; +use axum::extract::{Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::{middleware, Router}; +use axum::error_handling::HandleErrorLayer; use axum::routing::{get, post}; +use sqlx::{PgPool}; +use tower_csrf::{CrossOriginProtectionLayer, ProtectionError}; +use tower_governor::governor::GovernorConfig; use tower_http::compression::CompressionLayer; use tower_http::trace::TraceLayer; +use tower_sessions::Session; use crate::web::content::assets::static_handler; use crate::web::content::style; +use crate::web::service::auth::session::session_layer; use crate::web::service::{ AppState }; +use tower_governor::GovernorLayer; pub(crate) trait Route<'a, AD> { const PATH: &'static str; const NAME: &'static str = "MicroTLD"; - async fn get_content(state: State, query: AD) -> impl IntoResponse { + async fn process(session: Session, state: State, query: AD) -> impl IntoResponse { "You should fill out get_content for this route." } } -fn auth_routes() -> Router { - Router::new() - .route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::get_content)) - .route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::get_content)) - .route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::get_content)) - .route(auth::Login::PATH, post(auth::Login::get_content)) - .route(auth::Logout::PATH, post(auth::Logout::get_content)) - .route(auth::Register::PATH, post(auth::Register::get_content)) +async fn my_custom_middleware( + req: Request, + next: Next, +) -> Response { + // Perform actions BEFORE the route handler runs + let start_time = Instant::now(); + let path = req.uri().path().to_string(); + + // Pass the request to the rest of the application + let response = next.run(req).await; + + // Perform actions AFTER the route handler runs + let duration = start_time.elapsed(); + println!("Request to '{}' took {:?}", path, duration); + + response } -pub fn apply_routes(router: axum::Router) -> Router { + + + +fn auth_routes() -> Router { + let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP + let governor_layer = GovernorLayer::new(Arc::new(governor_config)); + + Router::new() + .layer(governor_layer) + .route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::process)) + .route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::process)) + .route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::process)) + .route(auth::Login::PATH, post(auth::Login::process)) + .route(auth::Logout::PATH, post(auth::Logout::process)) + .route(auth::Register::PATH, post(auth::Register::process)) + .route(auth::Confirm::PATH, get(auth::Confirm::process)) +} + +pub async fn apply_routes(router: axum::Router, pool: PgPool) -> Router { let css = style::stylesheet(); - router.route(root::Root::PATH, get(root::Root::get_content)) + let csrf_layer = tower::ServiceBuilder::new() + .layer(HandleErrorLayer::new( + |error: Box| async move { + if error.downcast_ref::().is_some() { + (StatusCode::FORBIDDEN, "Cross-origin request blocked").into_response() + } else { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }, + )) + .layer(CrossOriginProtectionLayer::default()); + + router + .route(root::Root::PATH, get(root::Root::process)) .route(&css.route, get(css_handler)) .nest(auth::PATH, auth_routes()) .route("/assets/{*path}", get(static_handler)) + .layer(csrf_layer) + .layer(session_layer(pool).await) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) + .route_layer(middleware::from_fn(my_custom_middleware)) } async fn css_handler() -> impl IntoResponse { diff --git a/web/src/web/service/routes/root.rs b/web/src/web/service/routes/root.rs index 91ae1b2..a5146f4 100644 --- a/web/src/web/service/routes/root.rs +++ b/web/src/web/service/routes/root.rs @@ -1,6 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use maud::html; +use tower_sessions::Session; use crate::web::content::partials::page::page; use crate::web::service::AppState; use crate::web::service::routes::Route; @@ -10,8 +11,8 @@ pub(crate) struct Root; impl Route<'_, ()> for Root { const PATH: &'static str = "/"; - async fn get_content(State(_app_state): State, _query: ()) -> impl IntoResponse { - page(Self::NAME, Self::PATH, false, html! { + async fn process(session: Session, State(_app_state): State, _query: ()) -> impl IntoResponse { + page(Self::NAME, Self::PATH, !session.is_empty().await, html! { "Hello, world!" }) }