diff --git a/Cargo.lock b/Cargo.lock index 281d8db..2129f68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -121,6 +127,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -233,6 +248,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "blake2" @@ -370,6 +388,21 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -394,6 +427,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -403,6 +451,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -413,6 +476,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -420,16 +494,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -470,16 +565,26 @@ dependencies = [ ] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "etcetera" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] [[package]] name = "find-msvc-tools" @@ -497,18 +602,23 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -525,6 +635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -534,10 +645,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "futures-sink" -version = "0.3.31" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" @@ -552,7 +691,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -610,7 +752,9 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -618,17 +762,14 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "hashlink" -version = "0.11.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -647,6 +788,39 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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]] name = "http" version = "1.4.0" @@ -758,12 +932,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -842,6 +1119,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -855,16 +1141,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] -name = "libsqlite3-sys" -version = "0.36.0" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -910,6 +1219,16 @@ dependencies = [ "syn", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -970,10 +1289,10 @@ dependencies = [ "maud", "rand 0.10.0", "rand 0.8.5", - "rusqlite", "serde", "serde_json", "sha2", + "sqlx", "strum", "thiserror", "tokio", @@ -982,6 +1301,42 @@ dependencies = [ "uuid", ] +[[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.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -989,6 +1344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1003,6 +1359,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1021,7 +1383,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1037,6 +1399,15 @@ dependencies = [ "subtle", ] +[[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" @@ -1073,12 +1444,39 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[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.13.1" @@ -1094,6 +1492,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1205,6 +1612,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1235,30 +1651,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "rsqlite-vfs" -version = "0.1.0" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "hashbrown 0.16.1", - "thiserror", + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] -name = "rusqlite" -version = "0.38.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "bitflags", - "chrono", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", - "uuid", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1364,6 +1821,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1391,6 +1859,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1414,6 +1892,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1426,15 +1907,237 @@ dependencies = [ ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", + "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", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "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.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] @@ -1481,6 +2184,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1501,6 +2215,31 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -1529,6 +2268,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1613,9 +2363,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1637,18 +2399,63 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1703,6 +2510,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.109" @@ -1782,6 +2595,34 @@ dependencies = [ "semver", ] +[[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.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1841,13 +2682,31 @@ 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 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1859,6 +2718,37 @@ 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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1866,58 +2756,148 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.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_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[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_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.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_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.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_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.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_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2012,6 +2992,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -2032,6 +3041,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 27a0eb3..27e8383 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,10 @@ log = "0.4.29" maud = { version = "0.27.0", features = ["axum"] } rand = "0.10.0" rand08 = { version = "0.8.5", package = "rand" } -rusqlite = { version = "0.38.0", features = ["bundled", "chrono", "uuid"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10.9" +sqlx = { version = "0.8.6", features = ["postgres", "uuid", "chrono", "json", "runtime-tokio", "tls-rustls", "migrate"] } strum = { version = "0.27.0", features = ["derive"] } thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["full"] } diff --git a/build.rs b/build.rs index ddbabf6..2b86b36 100644 --- a/build.rs +++ b/build.rs @@ -6,6 +6,7 @@ use std::process::Command; fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=src/web"); + println!("cargo:rerun-if-changed=src/database/migrations"); let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown")); let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| String::from("unknown")); diff --git a/compose.yaml b/compose.yaml index 1a67466..1b3fde9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,5 @@ services: - mnemosyne: - container_name: mnemosyne + core: build: context: . target: final @@ -8,19 +7,33 @@ services: - 39321:39321 restart: unless-stopped volumes: - # Mnemosyne would greatly enjoy not having an ephemeral database. - # If you're okay with storing it side by side with the compose file, - # a bind mount like this is one way to do it. Remember to mkdir! - ./mnemodata:/app/data - # Another way is to use a docker volume. - # - mnemodata:/app/data environment: - # DATABASE_URL is crucial for Mnemosyne to work; it will fail without it. - # Point it at where you'd like your database to be. - - DATABASE_URL=/app/data/db.db - # Mnemosyne uses port 39321 for HTTP by default; - # - PORT=39321 + # - PORT=39321 # Mnemosyne uses port 39321 for HTTP by default; + - DATABASE_URL=postgres://mnemo:syne@postgres:5432/mnemosyne + networks: + - mnemosyne + depends_on: + - postgres + postgres: + image: postgres:18.2-alpine3.23 + restart: unless-stopped + ports: + - 5432:5432 + volumes: + - pg_volume:/var/lib/postgresql/data:rw + stop_grace_period: 120s + environment: + POSTGRES_USER: mnemo + POSTGRES_PASSWORD: syne + POSTGRES_DB: mnemosyne + networks: + - mnemosyne -# Declaring a volume for the docker volume example. -# volumes: -# mnemodata: +networks: + mnemosyne: + driver: bridge + +volumes: + pg_volume: + driver: local diff --git a/src/api/auth.rs b/src/api/auth.rs index edd55f2..0296d22 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,12 +1,14 @@ use axum::{ Form, Json, + extract::State, http::{HeaderMap, header}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; +use sqlx::PgPool; use crate::{ - database, + MnemoState, users::{ User, auth::{ @@ -23,10 +25,14 @@ pub struct LoginForm { password: String, } -fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> { - let u = authenticate_via_credentials(&creds.handle, &creds.password)?.required()?; - let conn = database::conn()?; - let (_, token) = Session::new_for_user(&conn, &u)?; +async fn login_common(pool: &PgPool, creds: LoginForm) -> Result<(String, String), AuthError> { + let mut conn = pool.acquire().await?; + let u = authenticate_via_credentials(&mut conn, &creds.handle, &creds.password) + .await? + .required()?; + + let (_, token) = Session::new_for_user(&mut conn, &u).await?; + let secure = match cfg!(debug_assertions) { false => "; Secure", true => "", @@ -38,12 +44,20 @@ fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> { ); Ok((token, cookie)) } -pub async fn login(Json(creds): Json) -> Result { - let (token, cookie) = login_common(creds)?; + +pub async fn login( + State(state): State, + Json(creds): Json, +) -> Result { + let (token, cookie) = login_common(&state.pool, creds).await?; Ok(([(header::SET_COOKIE, cookie)], token).into_response()) } -pub async fn login_form(Form(creds): Form) -> Result { - match login_common(creds) { + +pub async fn login_form( + State(state): State, + Form(creds): Form, +) -> Result { + match login_common(&state.pool, creds).await { Ok((_, cookie)) => { Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/dashboard")).into_response()) } @@ -51,17 +65,32 @@ pub async fn login_form(Form(creds): Form) -> Result Result { - let mut s = Session::authenticate(&headers)?.required()?; - let conn = database::conn()?; - s.revoke(&conn, Some(&User::get_by_id(&conn, s.user_id)?))?; +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + let mut s = Session::authenticate(&mut conn, &headers) + .await? + .required()?; + let user = User::get_by_id(&mut conn, s.user_id).await?; + s.revoke(&mut conn, Some(&user)).await?; + let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0"); Ok(([(header::SET_COOKIE, cookie)], "Logged out!").into_response()) } -pub async fn logout_form(headers: HeaderMap) -> Result { - let mut s = Session::authenticate(&headers)?.required()?; - let conn = database::conn()?; - s.revoke(&conn, Some(&User::get_by_id(&conn, s.user_id)?))?; + +pub async fn logout_form( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + let mut s = Session::authenticate(&mut conn, &headers) + .await? + .required()?; + let user = User::get_by_id(&mut conn, s.user_id).await?; + s.revoke(&mut conn, Some(&user)).await?; + let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0"); Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 592d409..1e74aaf 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,8 @@ use axum::{ routing::{delete, get, patch, post}, }; +use crate::MnemoState; + mod auth; mod persons; mod quotes; @@ -10,7 +12,7 @@ mod sessions; mod tags; mod users; -pub fn api_router() -> Router { +pub fn api_router() -> Router { Router::new() .route("/api/live", get(async || "Mnemosyne lives")) // auth @@ -48,4 +50,5 @@ pub fn api_router() -> Router { // quotes .route("/api/quotes", post(quotes::create)) .route("/api/quotes/{id}", get(quotes::get_by_id)) + .route("/api/quotes/search", get(quotes::get_by_query)) } diff --git a/src/api/persons.rs b/src/api/persons.rs index 5772f26..42f3cd3 100644 --- a/src/api/persons.rs +++ b/src/api/persons.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::Path, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; @@ -8,7 +8,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::{Name, Person}, @@ -21,26 +21,34 @@ use crate::{ pub const CANT_SET_PRIMARYNAME: &str = "You don't have permission to swap primary names."; -pub async fn get_all(headers: HeaderMap) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Person::get_all(&conn)?).into_response()) +pub async fn get_all( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Person::get_all(&mut conn).await?).into_response()) } + pub async fn get_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Person::get_by_id(&conn, id)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Person::get_by_id(&mut conn, id).await?).into_response()) } + pub async fn pid_names( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Person::get_by_id(&conn, id)?.get_all_names(&conn)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + let person = Person::get_by_id(&mut conn, id).await?; + Ok(Json(person.get_all_names(&mut conn).await?).into_response()) } #[derive(Deserialize)] @@ -49,38 +57,41 @@ pub struct PersonNameForm { } pub async fn create( + State(state): State, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - let p = Person::create(&tx, form.name, u.id)?; + let p = Person::create(&mut tx, form.name, u.id).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::CreatePerson { id: p.id, - pname: p.primary_name.as_str().to_string(), + pname: p.primary_name.clone(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; + Ok((StatusCode::CREATED, Json(p)).into_response()) } + pub async fn add_name( + State(state): State, Path(id): Path, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - let p = Person::get_by_id(&tx, id)?; - let n = p.add_name(&tx, form.name, u.id)?; + let p = Person::get_by_id(&mut tx, id).await?; + let n = p.add_name(&mut tx, form.name, u.id).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::AddPersonName { pid: p.id, @@ -88,39 +99,54 @@ pub async fn add_name( pn: p.primary_name, nn: n.name.clone(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; + Ok((StatusCode::CREATED, Json(n)).into_response()) } -pub async fn n_all(headers: HeaderMap) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Name::get_all(&conn)?).into_response()) +pub async fn n_all( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Name::get_all(&mut conn).await?).into_response()) } -pub async fn n_by_id(Path(id): Path, headers: HeaderMap) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Name::get_by_id(&conn, id)?).into_response()) -} -pub async fn n_setprimary( + +pub async fn n_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Name::get_by_id(&mut conn, id).await?).into_response()) +} - if !u.has_permission(&tx, Permission::ChangePersonPrimaryName)? { +pub async fn n_setprimary( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; + + if !u + .has_permission(&mut tx, Permission::ChangePersonPrimaryName) + .await? + { return Ok((StatusCode::FORBIDDEN, CANT_SET_PRIMARYNAME).into_response()); } - let mut n = Name::get_by_id(&tx, id)?; - let p = Person::get_by_id(&tx, n.person_id)?; - n.set_primary(&tx)?; + let mut n = Name::get_by_id(&mut tx, id).await?; + let p = Person::get_by_id(&mut tx, n.person_id).await?; + n.set_primary(&mut tx).await?; n.is_primary = true; + LogEntry::new( - &tx, + &mut tx, u, LogAction::SetPersonPrimaryName { pid: p.id, @@ -128,8 +154,9 @@ pub async fn n_setprimary( on: p.primary_name, nn: n.name.clone(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Json(n).into_response()) } diff --git a/src/api/quotes.rs b/src/api/quotes.rs index bde6b72..f22d257 100644 --- a/src/api/quotes.rs +++ b/src/api/quotes.rs @@ -1,15 +1,15 @@ use axum::{ Json, - extract::Path, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use chrono::{DateTime, FixedOffset}; +use chrono::NaiveDateTime; use serde::Deserialize; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::Name, @@ -21,54 +21,69 @@ use crate::{ }; pub async fn get_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Quote::get_by_id(&conn, id)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Quote::get_by_id(&mut conn, id).await?).into_response()) +} + +pub async fn get_by_query( + State(state): State, + headers: HeaderMap, + Json(q): Json, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Quote::get_by_search_query(&mut conn, &q, 20, 0).await?).into_response()) } #[derive(Deserialize)] pub struct QuoteLineForm { pub content: String, - pub name_id: Uuid, + pub name_ids: Vec, } #[derive(Deserialize)] pub struct QuoteCreateForm { pub lines: Vec, - pub timestamp: DateTime, + pub timestamp: NaiveDateTime, pub context: Option, pub location: Option, pub public: bool, } pub async fn create( + State(state): State, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - let lines = form - .lines - .into_iter() - .map(|l| Ok((l.content, Name::get_by_id(&tx, l.name_id)?))) - .collect::, CompositeError>>()?; + let mut lines = Vec::with_capacity(form.lines.len()); + for l in form.lines { + let mut names = Vec::with_capacity(l.name_ids.len()); + for id in l.name_ids { + names.push(Name::get_by_id(&mut tx, id).await?); + } + lines.push((l.content, names)); + } let q = Quote::create( - &tx, + &mut tx, lines, form.timestamp, form.context, form.location, u.id, form.public, - )?; + ) + .await?; - LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?; - tx.commit()?; + LogEntry::new(&mut tx, u, LogAction::CreateQuote { id: q.id }).await?; + tx.commit().await?; Ok((StatusCode::CREATED, Json(q)).into_response()) } diff --git a/src/api/sessions.rs b/src/api/sessions.rs index 942e3f9..152e1fd 100644 --- a/src/api/sessions.rs +++ b/src/api/sessions.rs @@ -1,13 +1,13 @@ use axum::{ Json, - extract::Path, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, users::{ @@ -21,15 +21,17 @@ use crate::{ const CANT_REVOKE: &str = "You don't have permission to revoke this user's sessions."; pub async fn get_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - let s = Session::get_by_id(&conn, id)?; + let mut conn = state.pool.acquire().await?; + let u = User::authenticate(&mut conn, &headers).await?.required()?; + let s = Session::get_by_id(&mut conn, id).await?; match s.user_id == u.id - || u.has_permission(&conn, Permission::ListOthersSessions) + || u.has_permission(&mut conn, Permission::ListOthersSessions) + .await .is_ok_and(|v| v) { true => Ok(Json(s).into_response()), @@ -38,25 +40,29 @@ pub async fn get_by_id( } pub async fn revoke_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - let mut s = Session::get_by_id(&tx, id)?; + let mut s = Session::get_by_id(&mut tx, id).await?; match s.user_id == u.id - || u.has_permission(&tx, Permission::RevokeOthersSessions) + || u.has_permission(&mut tx, Permission::RevokeOthersSessions) + .await .is_ok_and(|v| v) { true => { - s.revoke(&tx, Some(&u))?; - LogEntry::new(&tx, u, LogAction::ManuallyRevokeSession { id })?; - tx.commit()?; + s.revoke(&mut tx, Some(&u)).await?; + LogEntry::new(&mut tx, u, LogAction::ManuallyRevokeSession { id }).await?; + tx.commit().await?; Ok(Json(s).into_response()) } - false => match u.has_permission(&tx, Permission::ListOthersSessions)? { + false => match u + .has_permission(&mut tx, Permission::ListOthersSessions) + .await? + { true => Ok((StatusCode::FORBIDDEN, CANT_REVOKE).into_response()), false => Err(SessionError::NoSessionWithId(id))?, }, diff --git a/src/api/tags.rs b/src/api/tags.rs index 8051e53..9d31369 100644 --- a/src/api/tags.rs +++ b/src/api/tags.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::Path, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; @@ -8,7 +8,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, tags::{Tag, TagName}, @@ -24,101 +24,112 @@ const CANT_DEL_TAGS: &str = "You don't have permission to delete tags."; const CANT_RENAME_TAGS: &str = "You don't have permission to rename tags."; const TAG_DELETED: &str = "Tag deleted successfully."; -pub async fn get_all(headers: HeaderMap) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Tag::get_all(&conn)?).into_response()) +pub async fn get_all( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Tag::get_all(&mut conn).await?).into_response()) } pub async fn get_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Tag::get_by_id(&conn, id)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Tag::get_by_id(&mut conn, id).await?).into_response()) } pub async fn get_by_name( + State(state): State, Path(name): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(Tag::get_by_name(&conn, name)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Tag::get_by_name(&mut conn, name).await?).into_response()) } #[derive(Deserialize)] pub struct TagNameForm { name: TagName, } + pub async fn create( + State(state): State, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - if !u.has_permission(&tx, Permission::CreateTags)? { + if !u.has_permission(&mut tx, Permission::CreateTags).await? { return Ok((StatusCode::FORBIDDEN, CANT_MAKE_TAGS).into_response()); } - let t = Tag::create(&tx, form.name)?; + let t = Tag::create(&mut tx, form.name).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::CreateTag { id: t.id, name: t.name.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Json(t).into_response()) } pub async fn rename( + State(state): State, Path(id): Path, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - if !u.has_permission(&tx, Permission::RenameTags)? { + if !u.has_permission(&mut tx, Permission::RenameTags).await? { return Ok((StatusCode::FORBIDDEN, CANT_RENAME_TAGS).into_response()); } - let mut tag = Tag::get_by_id(&tx, id)?; + let mut tag = Tag::get_by_id(&mut tx, id).await?; let on = tag.name.as_str().to_string(); - tag.rename(&tx, form.name)?; + tag.rename(&mut tx, form.name).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::RenameTag { id, on, nn: tag.name.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Json(tag).into_response()) } -pub async fn delete(Path(id): Path, headers: HeaderMap) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; +pub async fn delete( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - if !u.has_permission(&tx, Permission::DeleteTags)? { + if !u.has_permission(&mut tx, Permission::DeleteTags).await? { return Ok((StatusCode::FORBIDDEN, CANT_DEL_TAGS).into_response()); } - let t = Tag::get_by_id(&tx, id)?; + let t = Tag::get_by_id(&mut tx, id).await?; let name = t.name.as_str().to_string(); - t.delete(&tx)?; - LogEntry::new(&tx, u, LogAction::DeleteTag { id, name })?; - tx.commit()?; + t.delete(&mut tx).await?; + LogEntry::new(&mut tx, u, LogAction::DeleteTag { id, name }).await?; + tx.commit().await?; Ok((StatusCode::OK, TAG_DELETED).into_response()) } diff --git a/src/api/users.rs b/src/api/users.rs index a11fc4d..f1ba2dd 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::Path, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; @@ -8,7 +8,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, users::{ @@ -25,32 +25,41 @@ const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually cr const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully."; const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully."; -pub async fn get_me(headers: HeaderMap) -> Result { - Ok(Json(User::authenticate(&headers)?.required()?).into_response()) +pub async fn get_me( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + Ok(Json(User::authenticate(&mut conn, &headers).await?.required()?).into_response()) } pub async fn get_by_id( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(User::get_by_id(&conn, id)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(User::get_by_id(&mut conn, id).await?).into_response()) } pub async fn get_by_handle( + State(state): State, Path(handle): Path, headers: HeaderMap, ) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(User::get_by_handle(&conn, handle)?).into_response()) + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(User::get_by_handle(&mut conn, handle).await?).into_response()) } -pub async fn get_all(headers: HeaderMap) -> Result { - User::authenticate(&headers)?.required()?; - let conn = database::conn()?; - Ok(Json(User::get_all(&conn)?).into_response()) +pub async fn get_all( + State(state): State, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(User::get_all(&mut conn).await?).into_response()) } #[derive(Deserialize)] @@ -58,60 +67,69 @@ pub struct HandleForm { handle: UserHandle, } pub async fn create( + State(state): State, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; - if !u.has_permission(&tx, Permission::ManuallyCreateUsers)? { + if !u + .has_permission(&mut tx, Permission::ManuallyCreateUsers) + .await? + { return Ok((StatusCode::FORBIDDEN, CANT_MANUALLY_MAKE_USERS).into_response()); } - let nu = User::create(&tx, form.handle)?; + let nu = User::create(&mut tx, form.handle).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::CreateUser { id: nu.id, handle: nu.handle.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Json(nu).into_response()) } + pub async fn change_handle( + State(state): State, Path(id): Path, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; let mut target = if u.id == id { u.clone() } else { - if !u.has_permission(&tx, Permission::ChangeOthersHandles)? { + if !u + .has_permission(&mut tx, Permission::ChangeOthersHandles) + .await? + { return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_HANDLE).into_response()); } - User::get_by_id(&tx, id)? + User::get_by_id(&mut tx, id).await? }; let old_handle = target.handle.as_str().to_string(); - target.set_handle(&tx, form.handle)?; + target.set_handle(&mut tx, form.handle).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::ChangeUserHandle { id: target.id, old: old_handle, new: target.handle.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(HANDLE_CHANGED_SUCCESS.into_response()) } @@ -121,30 +139,34 @@ pub struct ChangePasswordForm { password: String, } pub async fn change_password( + State(state): State, Path(id): Path, headers: HeaderMap, Json(form): Json, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; let mut target = if u.id == id { u.clone() } else { - if !u.has_permission(&tx, Permission::ChangeOthersPasswords)? { + if !u + .has_permission(&mut tx, Permission::ChangeOthersPasswords) + .await? + { return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_PASSW).into_response()); } - User::get_by_id(&tx, id)? + User::get_by_id(&mut tx, id).await? }; - target.set_password(&tx, Some(&form.password))?; + target.set_password(&mut tx, Some(&form.password)).await?; LogEntry::new( - &tx, + &mut tx, u, LogAction::ManuallyChangeUsersPassword { id: target.id }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(PASSW_CHANGED_SUCCESS.into_response()) } diff --git a/src/config.rs b/src/config.rs index 876ba57..debec4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,16 @@ -use std::io::{self, Write}; +use std::{ + env::var, + error::Error, + io::{self, Write}, + time::Duration, +}; use env_logger::fmt::Formatter; -use log::Record; +use log::{LevelFilter, Record}; +use sqlx::PgPool; + +/// Mnemosyne, the mother of the nine muses +pub const DEFAULT_PORT: u16 = 0x9999; // 39321 pub const REFERENCE_SPLASHES: &[&str] = &[ "quote engine", @@ -17,6 +26,44 @@ pub const REFERENCE_SPLASHES: &[&str] = &[ "over 100 lines of git history!", ]; +pub async fn init_pool() -> Result> { + Ok(sqlx::postgres::PgPoolOptions::new() + .max_connections(20) + .acquire_timeout(Duration::from_secs(3)) + .idle_timeout(Duration::from_secs(60)) + .connect(var("DATABASE_URL")?.as_str()) + .await?) +} + +pub fn port() -> Result> { + Ok(match std::env::var("PORT") { + Ok(p) => p.parse::()?, + Err(e) => match e { + std::env::VarError::NotPresent => DEFAULT_PORT, + _ => return Err(e)?, + }, + }) +} + +pub fn dotenv() -> Result<(), Box> { + if let Err(e) = dotenvy::dotenv() + && !e.not_found() + { + return Err(e.into()); + } + Ok(()) +} + +pub fn env_logger() -> Result<(), Box> { + env_logger::builder() + .filter_level(LevelFilter::Info) + .filter_module("sqlx", LevelFilter::Warn) + .parse_default_env() + .format(envlogger_write_format) + .init(); + Ok(()) +} + pub fn envlogger_write_format(buf: &mut Formatter, rec: &Record) -> io::Result<()> { let level_string = format!("{}", rec.level()); let level_style = buf.default_level_style(rec.level()); diff --git a/src/database/migrations/0001_init.sql b/src/database/migrations/0001_init.sql new file mode 100644 index 0000000..02ac9b0 --- /dev/null +++ b/src/database/migrations/0001_init.sql @@ -0,0 +1,134 @@ +CREATE EXTENSION citext; +CREATE EXTENSION pg_trgm; + +CREATE TABLE users( + id UUID NOT NULL PRIMARY KEY, + handle CITEXT NOT NULL UNIQUE, + password TEXT, + profpic TEXT +); +CREATE TABLE sessions ( + id UUID NOT NULL PRIMARY KEY, + token BYTEA NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id), + expiry TIMESTAMPTZ NOT NULL, + + revoked BOOLEAN NOT NULL DEFAULT FALSE, + revoked_at TIMESTAMPTZ DEFAULT NULL, + revoked_by UUID DEFAULT NULL REFERENCES users(id), + + CHECK( + (revoked = FALSE AND revoked_at IS NULL AND revoked_by IS NULL) OR + (revoked = TRUE AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL) + ) +); +CREATE INDEX sessions_by_userid ON sessions(user_id); +CREATE TABLE user_permissions ( + user_id UUID NOT NULL REFERENCES users(id), + permission TEXT NOT NULL, + state BOOLEAN NOT NULL, + PRIMARY KEY (user_id, permission) +); + +CREATE TABLE quotes ( + id UUID NOT NULL PRIMARY KEY, + timestamp TIMESTAMP NOT NULL, + location TEXT DEFAULT NULL, + context TEXT DEFAULT NULL, + created_by UUID REFERENCES users(id), + public BOOLEAN DEFAULT FALSE, + fts TEXT NOT NULL DEFAULT '' +); +CREATE INDEX quotes_by_creation_user ON quotes(created_by); +CREATE INDEX quotes_fts_trgm_idx ON quotes USING gin (fts gin_trgm_ops); +CREATE TABLE persons ( + id UUID NOT NULL PRIMARY KEY, + bio TEXT DEFAULT NULL, + profpic TEXT DEFAULT NULL +); +CREATE TABLE names ( + id UUID NOT NULL PRIMARY KEY, + name CITEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + person_id UUID NOT NULL REFERENCES persons(id) +); +CREATE INDEX names_by_personid ON names(person_id); +CREATE UNIQUE INDEX no_name_duplicate_for_same_person ON names(person_id, name); +CREATE UNIQUE INDEX primary_name_uniqueness ON names(person_id) WHERE is_primary = TRUE; +CREATE TABLE lines ( + id UUID NOT NULL PRIMARY KEY, + quote_id UUID NOT NULL REFERENCES quotes(id), + ordering SMALLINT NOT NULL, + content TEXT NOT NULL +); +CREATE INDEX lines_by_quoteid ON lines(quote_id); +CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering); +CREATE TABLE line_authors ( + line_id UUID REFERENCES lines(id), + name_id UUID REFERENCES names(id), + PRIMARY KEY (line_id, name_id) +); + +CREATE TABLE tags ( + id UUID NOT NULL PRIMARY KEY, + name CITEXT NOT NULL UNIQUE +); + +CREATE TABLE user_quote_likes ( + quote_id UUID NOT NULL REFERENCES quotes(id), + user_id UUID NOT NULL REFERENCES users(id), + PRIMARY KEY (quote_id, user_id) +); +CREATE INDEX quote_likes_by_user ON user_quote_likes(user_id); +CREATE INDEX quote_likes_by_quote ON user_quote_likes(quote_id); + +CREATE TABLE quote_tags ( + quote_id UUID NOT NULL REFERENCES quotes(id), + tag_id UUID NOT NULL REFERENCES tags(id), + PRIMARY KEY (quote_id, tag_id) +); +CREATE INDEX quote_tags_by_quote ON quote_tags(quote_id); +CREATE INDEX quote_tags_by_tag ON quote_tags(tag_id); + +CREATE TABLE logs ( + id UUID NOT NULL PRIMARY KEY, + actor UUID NOT NULL REFERENCES users(id), + target UUID, + actiontype TEXT NOT NULL, + payload JSONB +); +CREATE INDEX logs_by_actor ON logs(actor); +CREATE INDEX logs_by_target ON logs(target); + +CREATE OR REPLACE FUNCTION update_quote_fts_from_lines() +RETURNS TRIGGER AS $$ +DECLARE + affected_quote_id UUID; + quote_lines_content TEXT; +BEGIN + IF TG_OP = 'DELETE' THEN + affected_quote_id := OLD.quote_id; + ELSE + affected_quote_id := NEW.quote_id; + END IF; + + SELECT string_agg(content, ' ' ORDER BY ordering) + INTO quote_lines_content + FROM lines + WHERE quote_id = affected_quote_id; + + UPDATE quotes + SET fts = + COALESCE(quote_lines_content, '') || ' ' || + COALESCE(context, '') || ' ' || + COALESCE(location, '') + WHERE id = affected_quote_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_lines_update_quote_fts + AFTER INSERT OR UPDATE OR DELETE ON lines + FOR EACH ROW + EXECUTE FUNCTION update_quote_fts_from_lines(); diff --git a/src/database/migrations/2026-04-04--01.sql b/src/database/migrations/2026-04-04--01.sql deleted file mode 100644 index 9633405..0000000 --- a/src/database/migrations/2026-04-04--01.sql +++ /dev/null @@ -1,100 +0,0 @@ -CREATE TABLE users ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - handle TEXT NOT NULL UNIQUE COLLATE NOCASE, - password TEXT, -- hashed, nullable in case of OAuth2-only login - prof_pic TEXT -- serialized ProfilePic -); -CREATE TABLE sessions ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - token BLOB NOT NULL UNIQUE, - user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 bytes (userID) - expiry TEXT NOT NULL, -- RFC3339 into DateTime - revoked INTEGER NOT NULL DEFAULT 0, -- bool (int 0 or int 1) - revoked_at TEXT DEFAULT NULL, -- RFC3339 into DateTime - revoked_by BLOB DEFAULT NULL REFERENCES users(id) -- UUIDv7 bytes (userID) - - CHECK( - (revoked = 0 AND revoked_at IS NULL AND revoked_by IS NULL) OR - (revoked = 1 AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL) - ) -); -CREATE INDEX sessions_by_userid ON sessions(user_id); -CREATE TABLE user_permissions ( - user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes - permission TEXT NOT NULL, -- serialized name - PRIMARY KEY (user_id, permission) -) WITHOUT ROWID; - -CREATE TABLE quotes ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - timestamp TEXT NOT NULL, -- RFC3339 into DateTime - location TEXT, - context TEXT, - created_by BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes - public INTEGER NOT NULL DEFAULT 0 -- bool (int 0 or int 1) - -- this is to be followed by a bigger role-based viewership scoping mechanism -); -CREATE INDEX quotes_by_creation_user ON quotes(created_by); -CREATE TABLE persons ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - created_by BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes - bio TEXT, - prof_pic TEXT -- link probably -); -CREATE TABLE names ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - is_primary INTEGER NOT NULL DEFAULT 0, - person_id BLOB NOT NULL REFERENCES persons(id), - created_by BLOB NOT NULL REFERENCES users(id), - name TEXT NOT NULL -); -CREATE INDEX names_by_personid ON names(person_id); -CREATE UNIQUE INDEX no_name_duplicate_for_same_person ON names(person_id, name); -CREATE UNIQUE INDEX primary_name_uniqueness ON names(person_id) WHERE is_primary = 1; -CREATE TABLE lines ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes - name_id BLOB NOT NULL REFERENCES names(id), -- UUIDv7 as bytes - ordering INTEGER NOT NULL, - content TEXT NOT NULL -); -CREATE INDEX lines_by_quoteid ON lines(quote_id); -CREATE INDEX lines_by_nameid ON lines(name_id); -CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering); - -CREATE TABLE tags ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - tagname TEXT NOT NULL UNIQUE COLLATE NOCASE -); - -CREATE TABLE user_quote_likes ( - quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes - user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes - PRIMARY KEY (quote_id, user_id) -) WITHOUT ROWID; -CREATE INDEX likes_by_reverse_index ON user_quote_likes(user_id, quote_id); - -CREATE TABLE quote_tags ( - quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes - tag_id BLOB NOT NULL REFERENCES tags(id), -- UUIDv7 as bytes - PRIMARY KEY (quote_id, tag_id) -) WITHOUT ROWID; -CREATE INDEX quote_tags_reverse_index ON quote_tags(tag_id, quote_id); - -CREATE TABLE logs ( - id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - actor BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes - -- (userID with special cases: UUID::nil if system, UUID::max if infradmin) - -- ((infradmin & system shall both be users)) - target BLOB, -- Option - actiontype TEXT NOT NULL, - payload TEXT -); -CREATE INDEX logs_by_actor ON logs(actor); -CREATE INDEX logs_by_target ON logs(target); - --- all this to be followed by: --- - a better access scoping mechanism (role-based like discord) --- - photos just like quotes --- - OAuth2 login via Steam/GitHub/Discord/Google/Potato/Whatever --- - comments diff --git a/src/database/mod.rs b/src/database/mod.rs index e9f43ff..591f023 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,68 +1,14 @@ -use std::{env, error::Error, sync::LazyLock}; - -use axum::{http::StatusCode, response::IntoResponse}; -use rusqlite::{Connection, OptionalExtension}; - -macro_rules! migration { - ($name:literal) => { - ($name, include_str!(concat!("./migrations/", $name, ".sql"))) - }; -} -const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-04-04--01")]; - -pub static DB_URL: LazyLock = - LazyLock::new(|| env::var("DATABASE_URL").expect("DATABASE_URL is not set")); - -const PERSISTENT_PRAGMAS: &[&str] = &["PRAGMA journal_mode = WAL"]; -const CONNECTION_PRAGMAS: &[&str] = &["PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000"]; -const TABLE_MIGRATIONS: &str = r#" -CREATE TABLE IF NOT EXISTS migrations ( - id TEXT PRIMARY KEY, - time INTEGER DEFAULT (unixepoch()) -); -"#; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; #[derive(Debug, thiserror::Error)] #[error("{0}")] -pub struct DatabaseError(#[from] rusqlite::Error); +pub struct DatabaseError(#[from] sqlx::Error); impl IntoResponse for DatabaseError { - fn into_response(self) -> axum::response::Response { + fn into_response(self) -> Response { log::error!("[DB ERROR] {}", self); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error.").into_response() } } - -pub fn conn() -> Result { - let conn = Connection::open(&*DB_URL)?; - for pragma in CONNECTION_PRAGMAS { - conn.query_row(pragma, (), |_| Ok(())).optional()?; - } - Ok(conn) -} - -pub fn migrations() -> Result<(), Box> { - let conn = Connection::open(&*DB_URL)?; - for pragma in PERSISTENT_PRAGMAS { - conn.query_row(pragma, (), |_| Ok(()))?; - } - conn.execute(TABLE_MIGRATIONS, ())?; - - let mut changes = false; - for (key, sql) in MIGRATIONS { - let mut statement = conn.prepare("SELECT id, time FROM migrations WHERE id = ?1")?; - let query = statement.query_one([key], |_| Ok(())).optional()?; - if query.is_some() { - continue; - } - changes = true; - log::info!("Applying migration {key}..."); - - conn.execute_batch(sql)?; - conn.execute("INSERT INTO migrations(id) VALUES (?1)", [key])?; - } - - if changes { - log::info!("Migrations applied.") - } - Ok(()) -} diff --git a/src/error.rs b/src/error.rs index 506ee87..c94df0a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,6 @@ use axum::response::{IntoResponse, Response}; -use crate::{ - database::DatabaseError, - persons::PersonError, - quotes::QuoteError, - tags::TagError, - users::{UserError, auth::AuthError, sessions::SessionError}, - web::RedirectViaError, -}; +use crate::database::DatabaseError; pub struct CompositeError(Response); impl IntoResponse for CompositeError { @@ -28,18 +21,18 @@ macro_rules! composite_from { }; } composite_from!( - AuthError, - UserError, - SessionError, - TagError, - PersonError, - QuoteError, + crate::users::auth::AuthError, + crate::users::UserError, + crate::users::sessions::SessionError, + crate::tags::TagError, + crate::persons::PersonError, + crate::quotes::QuoteError, DatabaseError, - RedirectViaError, + // RedirectViaError, ); -impl From for CompositeError { - fn from(e: rusqlite::Error) -> Self { - CompositeError(DatabaseError::from(e).into_response()) +impl From for CompositeError { + fn from(value: sqlx::Error) -> Self { + CompositeError(DatabaseError::from(value).into_response()) } } diff --git a/src/logs.rs b/src/logs.rs index 4b3f1a1..940bf69 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -1,9 +1,9 @@ -use rusqlite::Connection; use serde::{Deserialize, Serialize}; -use strum::IntoStaticStr; +use sqlx::{PgConnection, Row}; +use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames}; use uuid::Uuid; -use crate::{database::DatabaseError, users::User}; +use crate::{database::DatabaseError, users::User, web::icons}; #[derive(Debug)] pub struct LogEntry { @@ -13,52 +13,91 @@ pub struct LogEntry { } impl LogEntry { - pub fn new(conn: &Connection, actor: User, data: LogAction) -> Result { + pub async fn new( + conn: &mut PgConnection, + actor: User, + data: LogAction, + ) -> Result { let log = LogEntry { id: Uuid::now_v7(), actor, data, }; let actiontype: &'static str = (&log.data).into(); - let payload = serde_json::to_string(&log.data).unwrap(); - conn.prepare( - "INSERT INTO logs(id, actor, target, actiontype, payload) VALUES (?1,?2,?3,?4,?5)", - )? - .execute(( - &log.id, - &log.actor.id, - log.data.get_target_id(), - actiontype, - payload, - ))?; + let payload = serde_json::to_value(&log.data).unwrap(); + sqlx::query( + "INSERT INTO logs(id, actor, target, actiontype, payload) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(log.id) + .bind(log.actor.id) + .bind(log.data.get_target_id()) + .bind(actiontype) + .bind(payload) + .execute(&mut *conn) + .await?; Ok(log) } - pub fn total_count(conn: &Connection) -> Result { - Ok(conn.query_row("SELECT COUNT(*) FROM logs", (), |r| r.get(0))?) + + pub async fn count( + conn: &mut PgConnection, + action_type: Option, + ) -> Result { + let count = match action_type { + Some(at) => { + let atstr: &'static str = at.into(); + sqlx::query_scalar("SELECT COUNT(*) FROM logs WHERE actiontype = $1") + .bind(atstr) + .fetch_one(&mut *conn) + .await? + } + None => { + sqlx::query_scalar("SELECT COUNT(*) FROM logs") + .fetch_one(&mut *conn) + .await? + } + }; + Ok(count) } - pub fn get_chronological_offset( - conn: &Connection, + + pub async fn get_chronological_offset( + conn: &mut PgConnection, + action_type: Option, offset: i64, limit: i64, ) -> Result, DatabaseError> { - Ok(conn - .prepare("SELECT id, actor, target, actiontype, payload FROM logs ORDER BY id DESC LIMIT ?1 OFFSET ?2")? - .query_map((limit, offset), |r| { - let payload: String = r.get(4)?; - Ok(LogEntry { - id: r.get(0)?, - actor: User::get_by_id(conn, r.get(1)?).unwrap(), - data: serde_json::from_str(&payload).unwrap(), - }) - })? - .collect::, _>>()?) + let mut qstr = String::from("SELECT id, actor, payload FROM logs "); + if action_type.is_some() { + qstr += "WHERE actiontype = $3 " + } + qstr += "ORDER BY id DESC LIMIT $1 OFFSET $2"; + + let q = sqlx::query(&qstr).bind(limit).bind(offset); + + let rows = match action_type { + Some(at) => { + let atstr: &'static str = at.into(); + q.bind(atstr).fetch_all(&mut *conn).await? + } + None => q.fetch_all(&mut *conn).await?, + }; + + let mut entries = Vec::new(); + for row in rows { + let payload: serde_json::Value = row.try_get("payload")?; + let actor_id: Uuid = row.try_get("actor")?; + entries.push(LogEntry { + id: row.try_get("id")?, + actor: User::get_by_id(&mut *conn, actor_id).await.unwrap(), + data: serde_json::from_value(payload).unwrap(), + }); + } + Ok(entries) } } -// #[derive(Debug, thiserror::Error)] -// pub enum LogError {} - -#[derive(Debug, IntoStaticStr, Serialize, Deserialize)] +#[derive(Debug, IntoStaticStr, Serialize, Deserialize, VariantNames, EnumDiscriminants)] +#[strum_discriminants(derive(EnumIter, IntoStaticStr, Serialize, Deserialize))] +#[strum_discriminants(name(LogActionDiscriminant))] pub enum LogAction { Initialize, RegenInfradmin, @@ -177,3 +216,45 @@ impl LogAction { } } } + +impl LogActionDiscriminant { + pub fn human_readable(&self) -> &'static str { + use LogActionDiscriminant as LAD; + match self { + LAD::Initialize => "Mnemosyne Initialization", + LAD::RegenInfradmin => "Infradmin Regeneration", + LAD::CreateUser => "User Creation", + LAD::ManuallyChangeUsersPassword => "Password Override", + LAD::CreateTag => "Tag Creation", + LAD::RenameTag => "Tag Rename", + LAD::DeleteTag => "Tag Deletion", + LAD::CreatePerson => "Person Creation", + LAD::ChangeUserHandle => "User Handle Change", + LAD::AddPersonName => "Person Name Addition", + LAD::DeletePersonName => "Person Name Deletion", + LAD::SetPersonPrimaryName => "Person Primary Name Set", + LAD::CreateQuote => "Quote Creation", + LAD::ManuallyRevokeSession => "Manual Session Revocation", + } + } + pub fn icon(&self) -> &'static str { + use LogActionDiscriminant as LAD; + match self { + LAD::Initialize => icons::LINE_DOT_RIGHT_HORIZONTAL, + // LAD::RegenInfradmin => + // LAD::CreateUser => + // LAD::ManuallyChangeUsersPassword => + // LAD::CreateTag => + // LAD::RenameTag => + // LAD::DeleteTag => + // LAD::CreatePerson => + // LAD::ChangeUserHandle => + // LAD::AddPersonName => + // LAD::DeletePersonName => + // LAD::SetPersonPrimaryName => + // LAD::CreateQuote => + // LAD::ManuallyRevokeSession => + _ => icons::GIT_COMMIT_VERTICAL, + } + } +} diff --git a/src/main.rs b/src/main.rs index 3fe8d0f..ce25cc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::error::Error; use axum::Router; +use sqlx::PgPool; use tokio::net::TcpListener; mod api; @@ -14,39 +15,30 @@ mod tags; mod users; mod web; -/// Mnemosyne, the mother of the nine muses -const DEFAULT_PORT: u16 = 0x9999; // 39321 - /// The string to be returned alongside HTTP 500 const ISE_MSG: &str = "Internal server error"; +#[derive(Debug, Clone)] +pub struct MnemoState { + pool: PgPool, +} + #[tokio::main] async fn main() -> Result<(), Box> { - if let Err(e) = dotenvy::dotenv() - && !e.not_found() - { - return Err(e.into()); - } - env_logger::builder() - .filter_level(log::LevelFilter::Info) - .parse_default_env() - .format(config::envlogger_write_format) - .init(); + config::dotenv()?; + config::env_logger()?; - database::migrations()?; + let pool = config::init_pool().await?; + sqlx::migrate!("src/database/migrations").run(&pool).await?; + log::info!("Migrations applied successfully."); users::auth::init_password_dummies(); - users::setup::initialise_reserved_users_if_needed()?; + users::setup::initialise_reserved_users_if_needed(&pool).await?; - let port = match std::env::var("PORT") { - Ok(p) => p.parse::()?, - Err(e) => match e { - std::env::VarError::NotPresent => DEFAULT_PORT, - _ => return Err(e)?, - }, - }; + let port = config::port()?; let r = Router::new() .merge(api::api_router()) - .merge(web::web_router()); + .merge(web::web_router()) + .with_state(MnemoState { pool }); let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?; log::info!("Listener bound to {}", l.local_addr()?); diff --git a/src/persons/mod.rs b/src/persons/mod.rs index f4a04a9..f3e066b 100644 --- a/src/persons/mod.rs +++ b/src/persons/mod.rs @@ -2,8 +2,8 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::database::DatabaseError; @@ -12,7 +12,6 @@ use crate::database::DatabaseError; pub struct Person { pub id: Uuid, pub primary_name: String, - pub created_by: Uuid, } #[derive(Serialize)] @@ -20,7 +19,6 @@ pub struct Name { pub id: Uuid, pub is_primary: bool, pub person_id: Uuid, - pub created_by: Uuid, pub name: String, } @@ -39,169 +37,213 @@ pub enum PersonError { } impl Person { - pub fn total_count(conn: &Connection) -> Result { - Ok(conn.query_row("SELECT COUNT(*) FROM persons", (), |r| r.get(0))?) - } - pub fn get_all(conn: &Connection) -> Result, PersonError> { - Ok(conn - .prepare("SELECT p.id, p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1")? - .query_map((), |r| { - Ok(Person { - id: r.get(0)?, - created_by: r.get(1)?, - primary_name: r.get(2)?, - }) - })? - .collect::, _>>()?) + pub async fn total_count(conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons") + .fetch_one(conn) + .await?; + Ok(count) } - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let res = conn - .prepare("SELECT p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1 WHERE p.id = ?1")? - .query_one((&id,), |r| { - Ok(Person { - id, - created_by: r.get(0)?, - primary_name: r.get(1)?, - }) - }) - .optional()?; - match res { - Some(p) => Ok(p), + pub async fn get_all(conn: &mut PgConnection) -> Result, PersonError> { + let rows = sqlx::query( + "SELECT p.id, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true" + ) + .fetch_all(conn) + .await?; + + let mut persons = Vec::with_capacity(rows.len()); + for r in rows { + persons.push(Person { + id: r.try_get("id")?, + primary_name: r.try_get("name")?, + }); + } + Ok(persons) + } + + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let row = sqlx::query( + "SELECT n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true WHERE p.id = $1" + ) + .bind(id) + .fetch_optional(conn) + .await?; + + match row { + Some(r) => Ok(Person { + id, + primary_name: r.try_get("name")?, + }), None => Err(PersonError::NoPersonWithId(id)), } } - pub fn get_in_quote_count(&self, conn: &Connection) -> Result { - Ok(conn - .prepare( - r#" - SELECT COUNT(DISTINCT l.quote_id) AS quote_count - FROM lines l WHERE l.name_id IN ( - SELECT id FROM names WHERE person_id = ?1 + pub async fn get_in_quote_count(&self, conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT l.quote_id) + FROM lines l JOIN line_authors la ON l.id = la.line_id + WHERE la.name_id IN ( + SELECT id FROM names WHERE person_id = $1 );"#, - )? - .query_one((self.id,), |r| Ok(r.get(0)?))?) + ) + .bind(self.id) + .fetch_one(conn) + .await?; + + Ok(count) } - pub fn get_all_names(&self, conn: &Connection) -> Result, PersonError> { - Ok(conn - .prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE person_id = ?1 ORDER BY id")? - .query_map((&self.id,), |r| { - Ok(Name { - id: r.get(0)?, - is_primary: r.get(1)?, - person_id: r.get(2)?, - created_by: r.get(3)?, - name: r.get(4)?, - }) - })? - .collect::, _>>()?) + pub async fn get_all_names(&self, conn: &mut PgConnection) -> Result, PersonError> { + let rows = sqlx::query( + "SELECT id, is_primary, person_id, name FROM names WHERE person_id = $1 ORDER BY id", + ) + .bind(self.id) + .fetch_all(conn) + .await?; + + let mut names = Vec::with_capacity(rows.len()); + for r in rows { + names.push(Name { + id: r.try_get("id")?, + is_primary: r.try_get("is_primary")?, + person_id: r.try_get("person_id")?, + name: r.try_get("name")?, + }); + } + Ok(names) } - pub fn add_name( + pub async fn add_name( &self, - conn: &Connection, + conn: &mut PgConnection, name: String, - created_by: Uuid, + _created_by: Uuid, ) -> Result { let id = Uuid::now_v7(); - conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? - .execute((id, 0, self.id, created_by, &name))?; + sqlx::query("INSERT INTO names (id, is_primary, person_id, name) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(false) + .bind(self.id) + .bind(&name) + .execute(conn) + .await?; + Ok(Name { id, is_primary: false, person_id: self.id, - created_by, name, }) } - pub fn create( - conn: &Connection, + pub async fn create( + conn: &mut PgConnection, primary_name: String, - created_by: Uuid, + _created_by: Uuid, ) -> Result { let person_id = Uuid::now_v7(); let name_id = Uuid::now_v7(); - conn.prepare("INSERT INTO persons(id, created_by) VALUES (?1, ?2)")? - .execute((person_id, created_by))?; - conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? - .execute((name_id, 1, person_id, created_by, &primary_name))?; + sqlx::query("INSERT INTO persons(id) VALUES ($1)") + .bind(person_id) + .execute(&mut *conn) + .await?; + + sqlx::query("INSERT INTO names (id, is_primary, person_id, name) VALUES ($1, $2, $3, $4)") + .bind(name_id) + .bind(true) + .bind(person_id) + .bind(&primary_name) + .execute(&mut *conn) + .await?; Ok(Person { id: person_id, primary_name, - created_by, }) } } impl Name { - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let res = conn - .prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE id = ?1")? - .query_one((&id,), |r| { - Ok(Name { - id: r.get(0)?, - is_primary: r.get(1)?, - person_id: r.get(2)?, - created_by: r.get(3)?, - name: r.get(4)?, - }) - }) - .optional()?; - match res { - Some(n) => Ok(n), + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let row = sqlx::query("SELECT id, is_primary, person_id, name FROM names WHERE id = $1") + .bind(id) + .fetch_optional(conn) + .await?; + + match row { + Some(r) => Ok(Name { + id: r.try_get("id")?, + is_primary: r.try_get("is_primary")?, + person_id: r.try_get("person_id")?, + name: r.try_get("name")?, + }), None => Err(PersonError::NoNameWithId(id)), } } - pub fn get_all(conn: &Connection) -> Result, PersonError> { - Ok(conn - .prepare("SELECT id, is_primary, person_id, created_by, name FROM names")? - .query_map((), |r| { - Ok(Name { - id: r.get(0)?, - is_primary: r.get(1)?, - person_id: r.get(2)?, - created_by: r.get(3)?, - name: r.get(4)?, - }) - })? - .collect::, _>>()?) + + pub async fn get_all(conn: &mut PgConnection) -> Result, PersonError> { + let rows = sqlx::query("SELECT id, is_primary, person_id, name FROM names") + .fetch_all(conn) + .await?; + + let mut names = Vec::with_capacity(rows.len()); + for r in rows { + names.push(Name { + id: r.try_get("id")?, + is_primary: r.try_get("is_primary")?, + person_id: r.try_get("person_id")?, + name: r.try_get("name")?, + }); + } + Ok(names) } - pub fn times_attributed(&self, conn: &Connection) -> Result { - Ok(conn - .prepare("SELECT COUNT(*) FROM lines WHERE name_id = ?1")? - .query_row((&self.id,), |r| r.get(0))?) + + pub async fn times_attributed(&self, conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM line_authors WHERE name_id = $1") + .bind(self.id) + .fetch_one(conn) + .await?; + Ok(count) } - pub fn delete(self, conn: &Connection) -> Result<(), PersonError> { - conn.prepare("DELETE FROM names WHERE id = ?1")? - .execute((&self.id,))?; + + pub async fn delete(self, conn: &mut PgConnection) -> Result<(), PersonError> { + sqlx::query("DELETE FROM names WHERE id = $1") + .bind(self.id) + .execute(conn) + .await?; Ok(()) } - pub fn set_primary(&mut self, conn: &Connection) -> Result<(), PersonError> { + + pub async fn set_primary(&mut self, conn: &mut PgConnection) -> Result<(), PersonError> { if self.is_primary { return Err(PersonError::AlreadyPrimary); } - conn.prepare("UPDATE names SET is_primary = 0 WHERE person_id = ?1 AND is_primary = 1")? - .execute((&self.person_id,))?; - conn.prepare("UPDATE names SET is_primary = 1 WHERE id = ?1")? - .execute((&self.id,))?; + + sqlx::query( + "UPDATE names SET is_primary = false WHERE person_id = $1 AND is_primary = true", + ) + .bind(self.person_id) + .execute(&mut *conn) + .await?; + + sqlx::query("UPDATE names SET is_primary = true WHERE id = $1") + .bind(self.id) + .execute(&mut *conn) + .await?; self.is_primary = true; Ok(()) } } -impl From for PersonError { - fn from(error: rusqlite::Error) -> Self { - if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error - && e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE - && msg.contains("name") - { - return PersonError::NameAlreadyExists; +impl From for PersonError { + fn from(error: sqlx::Error) -> Self { + if let sqlx::Error::Database(err) = &error { + if err.is_unique_violation() && err.message().contains("name") { + return PersonError::NameAlreadyExists; + } } PersonError::DatabaseError(DatabaseError::from(error)) } diff --git a/src/quotes/mod.rs b/src/quotes/mod.rs index 841aa27..4c0fc72 100644 --- a/src/quotes/mod.rs +++ b/src/quotes/mod.rs @@ -1,7 +1,10 @@ -use axum::{http::StatusCode, response::IntoResponse}; -use chrono::{DateTime, FixedOffset, Utc}; -use rusqlite::{Connection, OptionalExtension}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, NaiveDateTime, Utc}; use serde::Serialize; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::{database::DatabaseError, persons::Name}; @@ -10,7 +13,7 @@ use crate::{database::DatabaseError, persons::Name}; pub struct Quote { pub id: Uuid, pub lines: Vec, - pub timestamp: DateTime, + pub timestamp: NaiveDateTime, pub location: Option, pub context: Option, pub created_by: Uuid, @@ -20,7 +23,7 @@ pub struct Quote { #[derive(Serialize)] pub struct QuoteLine { pub id: Uuid, - pub attribution: Name, + pub attribution: Vec, pub content: String, } @@ -44,52 +47,65 @@ impl Quote { } impl Quote { - pub fn total_count(conn: &Connection) -> Result { - Ok(conn.query_row("SELECT COUNT(*) FROM quotes", (), |r| r.get(0))?) + pub async fn total_count(conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quotes") + .fetch_one(conn) + .await?; + Ok(count) } - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let quotemain = conn - .prepare( - "SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = ?1", - )? - .query_row((id,), |r| { - Ok(( - r.get::<_, DateTime>(0)?, - r.get::<_, Option>(1)?, - r.get::<_, Option>(2)?, - r.get::<_, Uuid>(3)?, - r.get::<_, bool>(4)?, - )) - }) - .optional()?; - let (timestamp, location, context, created_by, public) = match quotemain { - Some(data) => data, + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let quotemain = sqlx::query( + "SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = $1", + ) + .bind(id) + .fetch_optional(&mut *conn) + .await?; + + let row = match quotemain { + Some(row) => row, None => return Err(QuoteError::NoQuoteWithId(id)), }; - let lines = conn - .prepare( - r#" - SELECT l.id, l.content, n.id, n.is_primary, n.person_id, n.created_by, n.name - FROM lines AS l JOIN names AS n ON l.name_id = n.id - WHERE l.quote_id = ?1 ORDER BY l.ordering - "#, - )? - .query_map((id,), |r| { - Ok(QuoteLine { - id: r.get(0)?, - content: r.get(1)?, - attribution: Name { - id: r.get(2)?, - is_primary: r.get(3)?, - person_id: r.get(4)?, - created_by: r.get(5)?, - name: r.get(6)?, - }, - }) - })? - .collect::, _>>()?; + let timestamp: NaiveDateTime = row.try_get("timestamp")?; + let location: Option = row.try_get("location")?; + let context: Option = row.try_get("context")?; + let created_by: Uuid = row.try_get("created_by")?; + let public: bool = row.try_get("public")?; + + let line_rows = sqlx::query( + r#" + SELECT l.id, l.content, n.id as name_id, n.is_primary, n.person_id, n.name + FROM lines AS l + JOIN line_authors AS la ON l.id = la.line_id + JOIN names AS n ON la.name_id = n.id + WHERE l.quote_id = $1 ORDER BY l.ordering + "#, + ) + .bind(id) + .fetch_all(&mut *conn) + .await?; + + let mut lines: Vec = Vec::new(); + for r in line_rows { + let line_id: Uuid = r.try_get("id")?; + let name = Name { + id: r.try_get("name_id")?, + is_primary: r.try_get("is_primary")?, + person_id: r.try_get("person_id")?, + name: r.try_get("name")?, + }; + + if let Some(last) = lines.last_mut().filter(|l| l.id == line_id) { + last.attribution.push(name); + } else { + lines.push(QuoteLine { + id: line_id, + content: r.try_get("content")?, + attribution: vec![name], + }); + } + } Ok(Quote { id, @@ -101,60 +117,102 @@ impl Quote { public, }) } - pub fn get_newest(conn: &Connection) -> Result, QuoteError> { - let id: Option = conn - .query_row("SELECT id FROM quotes ORDER BY id DESC LIMIT 1", (), |r| { - r.get(0) - }) - .optional()?; - match id { - Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), + pub async fn get_newest(conn: &mut PgConnection) -> Result, QuoteError> { + let id_opt: Option = + sqlx::query_scalar("SELECT id FROM quotes ORDER BY id DESC LIMIT 1") + .fetch_optional(&mut *conn) + .await?; + + match id_opt { + Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)), None => Ok(None), } } - pub fn get_newest_public(conn: &Connection) -> Result, QuoteError> { - let id: Option = conn - .query_row( - "SELECT id FROM quotes WHERE public = 1 ORDER BY id DESC LIMIT 1", - (), - |r| r.get(0), - ) - .optional()?; - match id { - Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), + pub async fn get_newest_public(conn: &mut PgConnection) -> Result, QuoteError> { + let id_opt: Option = sqlx::query_scalar( + "SELECT id FROM quotes WHERE public = true ORDER BY id DESC LIMIT 1", + ) + .fetch_optional(&mut *conn) + .await?; + + match id_opt { + Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)), None => Ok(None), } } - pub fn get_random(conn: &Connection) -> Result, QuoteError> { - let id: Option = conn - .query_row("SELECT id FROM quotes ORDER BY RANDOM() LIMIT 1", (), |r| { - r.get(0) - }) - .optional()?; - match id { - Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), + pub async fn get_random(conn: &mut PgConnection) -> Result, QuoteError> { + let id_opt: Option = + sqlx::query_scalar("SELECT id FROM quotes ORDER BY RANDOM() LIMIT 1") + .fetch_optional(&mut *conn) + .await?; + + match id_opt { + Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)), None => Ok(None), } } - pub fn get_chronological_offset( - conn: &Connection, + + pub async fn get_chronological_offset( + conn: &mut PgConnection, offset: i64, limit: i64, ) -> Result, QuoteError> { - let ids = conn - .prepare("SELECT id FROM quotes ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2")? - .query_map((limit, offset), |r| r.get(0))? - .collect::, _>>()?; + let ids: Vec = + sqlx::query_scalar("SELECT id FROM quotes ORDER BY timestamp DESC LIMIT $1 OFFSET $2") + .bind(limit) + .bind(offset) + .fetch_all(&mut *conn) + .await?; - ids.iter().map(|id| Self::get_by_id(conn, *id)).collect() + let mut quotes = Vec::with_capacity(ids.len()); + for id in ids { + quotes.push(Self::get_by_id(&mut *conn, id).await?); + } + Ok(quotes) } - pub fn create( - conn: &Connection, - lines: Vec<(String, Name)>, - timestamp: DateTime, + + pub async fn search_query_count( + conn: &mut PgConnection, + query: &str, + ) -> Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM quotes WHERE fts ILIKE '%' || $1 || '%'") + .bind(query) + .fetch_one(&mut *conn) + .await?; + Ok(count) + } + + pub async fn get_by_search_query( + conn: &mut PgConnection, + query: &str, + offset: i64, + limit: i64, + ) -> Result, QuoteError> { + let ids: Vec = sqlx::query_scalar( + "SELECT id FROM quotes WHERE fts ILIKE '%' || $1 || '%' LIMIT $2 OFFSET $3", + ) + .bind(query) + .bind(limit) + .bind(offset) + .fetch_all(&mut *conn) + .await?; + + let mut quotes = Vec::with_capacity(ids.len()); + for id in ids { + quotes.push(Self::get_by_id(&mut *conn, id).await?); + } + + Ok(quotes) + } + + pub async fn create( + conn: &mut PgConnection, + lines: Vec<(String, Vec)>, + timestamp: NaiveDateTime, context: Option, location: Option, created_by: Uuid, @@ -165,27 +223,52 @@ impl Quote { } let quote_id = Uuid::now_v7(); - let lines: Vec<(Uuid, String, Name)> = lines + let lines: Vec<(Uuid, String, Vec)> = lines .into_iter() .map(|(c, a)| (Uuid::now_v7(), c, a)) .collect(); - let mut quote_stmt = conn.prepare( + sqlx::query( r#" INSERT INTO quotes (id, timestamp, location, context, created_by, public) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + VALUES ($1, $2, $3, $4, $5, $6) "#, - )?; - quote_stmt.execute((quote_id, timestamp, &location, &context, created_by, public))?; + ) + .bind(quote_id) + .bind(timestamp) + .bind(&location) + .bind(&context) + .bind(created_by) + .bind(public) + .execute(&mut *conn) + .await?; - let mut line_stmt = conn.prepare( - r#" - INSERT INTO lines (id, quote_id, content, name_id, ordering) - VALUES (?1, ?2, ?3, ?4, ?5) - "#, - )?; for (ordering, (id, content, attr)) in lines.iter().enumerate() { - line_stmt.execute((id, quote_id, content, attr.id, ordering as i64))?; + sqlx::query( + r#" + INSERT INTO lines (id, quote_id, content, ordering) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(id) + .bind(quote_id) + .bind(content) + .bind(ordering as i16) + .execute(&mut *conn) + .await?; + + for a in attr { + sqlx::query( + r#" + INSERT INTO line_authors (line_id, name_id) + VALUES ($1, $2) + "#, + ) + .bind(id) + .bind(a.id) + .execute(&mut *conn) + .await?; + } } Ok(Quote { @@ -207,14 +290,14 @@ impl Quote { } } -impl From for QuoteError { - fn from(error: rusqlite::Error) -> Self { +impl From for QuoteError { + fn from(error: sqlx::Error) -> Self { QuoteError::DatabaseError(DatabaseError::from(error)) } } impl IntoResponse for QuoteError { - fn into_response(self) -> axum::response::Response { + fn into_response(self) -> Response { match self { Self::DatabaseError(e) => e.into_response(), Self::NoQuoteWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), diff --git a/src/tags.rs b/src/tags.rs index 9ed697f..f467694 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -4,12 +4,8 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use rusqlite::{ - Connection, OptionalExtension, Result as RusqliteResult, ToSql, - ffi::SQLITE_CONSTRAINT_UNIQUE, - types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, -}; use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::database::DatabaseError; @@ -21,70 +17,95 @@ pub struct Tag { } impl Tag { - pub fn total_count(conn: &Connection) -> Result { - Ok(conn.query_row("SELECT COUNT(*) FROM tags", (), |r| r.get(0))?) + pub async fn total_count(conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tags") + .fetch_one(conn) + .await?; + Ok(count) } - pub fn get_all(conn: &Connection) -> Result, TagError> { - Ok(conn - .prepare("SELECT id, tagname FROM tags")? - .query_map((), |r| { - Ok(Tag { - id: r.get(0)?, - name: r.get(1)?, - }) - })? - .collect::, _>>()?) + + pub async fn get_all(conn: &mut PgConnection) -> Result, TagError> { + let rows = sqlx::query("SELECT id, name FROM tags") + .fetch_all(conn) + .await?; + + let mut tags = Vec::with_capacity(rows.len()); + for r in rows { + let name_str: String = r.try_get("name")?; + tags.push(Tag { + id: r.try_get("id")?, + name: TagName::new(name_str)?, + }); + } + Ok(tags) } - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let res = conn - .prepare("SELECT tagname FROM tags WHERE id = ?1")? - .query_one((&id,), |r| { + + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let res = sqlx::query("SELECT name FROM tags WHERE id = $1") + .bind(id) + .fetch_optional(conn) + .await?; + + match res { + Some(r) => { + let name_str: String = r.try_get("name")?; Ok(Tag { id, - name: r.get(0)?, + name: TagName::new(name_str)?, }) - }) - .optional()?; - match res { - Some(t) => Ok(t), + } None => Err(TagError::NoTagWithId(id)), } } - pub fn get_tagged_quotes_count(&self, conn: &Connection) -> Result { - Ok(conn - .prepare("SELECT COUNT(*) FROM quote_tags WHERE tag_id = ?1")? - .query_one((self.id,), |r| Ok(r.get(0)?))?) + + pub async fn get_tagged_quotes_count(&self, conn: &mut PgConnection) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quote_tags WHERE tag_id = $1") + .bind(self.id) + .fetch_one(conn) + .await?; + Ok(count) } - pub fn get_by_name(conn: &Connection, name: TagName) -> Result { - let res = conn - .prepare("SELECT id, tagname FROM tags WHERE tagname = ?1")? - .query_one((&name,), |r| { - Ok(Tag { - id: r.get(0)?, - name: r.get(1)?, - }) - }) - .optional()?; + + pub async fn get_by_name(conn: &mut PgConnection, name: TagName) -> Result { + let res = sqlx::query("SELECT id FROM tags WHERE name = $1") + .bind(name.as_str()) + .fetch_optional(conn) + .await?; + match res { - Some(u) => Ok(u), + Some(r) => Ok(Tag { + id: r.try_get("id")?, + name, + }), None => Err(TagError::NoTagWithName(name)), } } - pub fn create(conn: &Connection, name: TagName) -> Result { + + pub async fn create(conn: &mut PgConnection, name: TagName) -> Result { let id = Uuid::now_v7(); - conn.prepare("INSERT INTO tags(id, tagname) VALUES (?1, ?2)")? - .execute((id, &name))?; + sqlx::query("INSERT INTO tags(id, name) VALUES ($1, $2)") + .bind(id) + .bind(name.as_str()) + .execute(conn) + .await?; Ok(Tag { id, name }) } - pub fn rename(&mut self, conn: &Connection, name: TagName) -> Result<(), TagError> { - conn.prepare("UPDATE tags SET tagname = ?1 WHERE id = ?2")? - .execute((&name, self.id))?; + + pub async fn rename(&mut self, conn: &mut PgConnection, name: TagName) -> Result<(), TagError> { + sqlx::query("UPDATE tags SET name = $1 WHERE id = $2") + .bind(name.as_str()) + .bind(self.id) + .execute(conn) + .await?; self.name = name; Ok(()) } - pub fn delete(self, conn: &Connection) -> Result<(), TagError> { - conn.prepare("DELETE FROM tags WHERE id = ?1")? - .execute((self.id,))?; + + pub async fn delete(self, conn: &mut PgConnection) -> Result<(), TagError> { + sqlx::query("DELETE FROM tags WHERE id = $1") + .bind(self.id) + .execute(conn) + .await?; Ok(()) } } @@ -102,17 +123,18 @@ pub enum TagError { #[error("Database error: {0}")] DatabaseError(#[from] DatabaseError), } -impl From for TagError { - fn from(error: rusqlite::Error) -> Self { - if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error - && e.extended_code == SQLITE_CONSTRAINT_UNIQUE - && msg.contains("tagname") - { - return TagError::TagAlreadyExists; + +impl From for TagError { + fn from(error: sqlx::Error) -> Self { + if let sqlx::Error::Database(err) = &error { + if err.is_unique_violation() && err.message().contains("tagname") { + return TagError::TagAlreadyExists; + } } TagError::DatabaseError(DatabaseError::from(error)) } } + impl IntoResponse for TagError { fn into_response(self) -> Response { match self { @@ -125,7 +147,8 @@ impl IntoResponse for TagError { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] +#[sqlx(transparent)] #[serde(into = "String")] #[serde(try_from = "String")] pub struct TagName(String); @@ -224,18 +247,6 @@ impl From for String { } } -impl ToSql for TagName { - fn to_sql(&self) -> RusqliteResult> { - self.0.to_sql() - } -} - -impl FromSql for TagName { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - TagName::from_str(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))) - } -} - #[test] #[should_panic] fn tagname_leading_dash_fail() { diff --git a/src/users/auth/implementation.rs b/src/users/auth/implementation.rs index 5366ce7..2a6a173 100644 --- a/src/users/auth/implementation.rs +++ b/src/users/auth/implementation.rs @@ -7,12 +7,12 @@ use axum::{ response::{IntoResponse, Response}, }; use base64::{Engine, prelude::BASE64_STANDARD}; -use rusqlite::OptionalExtension; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::{ ISE_MSG, - database::{self, DatabaseError}, + database::DatabaseError, users::{ User, auth::{ @@ -53,8 +53,8 @@ impl IntoResponse for AuthError { } } } -impl From for AuthError { - fn from(value: rusqlite::Error) -> Self { +impl From for AuthError { + fn from(value: sqlx::Error) -> Self { AuthError::DatabaseError(DatabaseError::from(value)) } } @@ -122,21 +122,27 @@ impl<'a> AuthScheme<'a> { } impl UserAuthenticate for User { - fn authenticate(headers: &HeaderMap) -> Result, AuthError> { + async fn authenticate( + conn: &mut PgConnection, + headers: &HeaderMap, + ) -> Result, AuthError> { let (basic_auth, bearer_auth) = auth_common(headers); match (basic_auth, bearer_auth) { - (Some(creds), _) => authenticate_basic(&creds), - (None, Some(token)) => authenticate_bearer(&token), + (Some(creds), _) => authenticate_basic(conn, &creds).await, + (None, Some(token)) => authenticate_bearer(conn, &token).await, _ => Ok(None), } } } impl SessionAuthenticate for Session { - fn authenticate(headers: &HeaderMap) -> Result, AuthError> { + async fn authenticate( + conn: &mut PgConnection, + headers: &HeaderMap, + ) -> Result, AuthError> { let (_, bearer_auth) = auth_common(headers); if let Some(token) = bearer_auth { - authenticate_bearer_with_session(&token) + authenticate_bearer_with_session(conn, &token).await } else { Ok(None) } @@ -181,52 +187,71 @@ fn auth_common(headers: &HeaderMap) -> (Option, Option) { (basic_auth, bearer_auth) } -fn authenticate_basic(credentials: &str) -> Result, AuthError> { +async fn authenticate_basic( + conn: &mut PgConnection, + credentials: &str, +) -> Result, AuthError> { let decoded = BASE64_STANDARD.decode(credentials)?; let credentials_str = String::from_utf8(decoded)?; let Some((handle, password)) = credentials_str.split_once(':') else { return Err(AuthError::InvalidFormat); }; - authenticate_via_credentials(handle, password) + authenticate_via_credentials(conn, handle, password).await } -pub fn authenticate_via_credentials( + +pub async fn authenticate_via_credentials( + conn: &mut PgConnection, handle: &str, password: &str, ) -> Result, AuthError> { - let conn = database::conn()?; - let user: Option<(Uuid, Option)> = conn - .prepare("SELECT id, password FROM users WHERE handle = ?1")? - .query_row([handle], |r| Ok((r.get(0)?, r.get(1)?))) - .optional()?; + let row = sqlx::query("SELECT id, password FROM users WHERE handle = $1") + .bind(handle) + .fetch_optional(&mut *conn) + .await?; - match user { - Some((id, Some(passhash))) => match User::match_hash_password(password, &passhash)? { - true => Ok(Some(User::get_by_id(&conn, id)?)), - false => Err(AuthError::InvalidCredentials), - }, - _ => { + match row { + Some(r) => { + let id: Uuid = r.try_get("id")?; + let passhash: Option = r.try_get("password")?; + match passhash { + Some(p) => match User::match_hash_password(password, &p)? { + true => Ok(Some(User::get_by_id(conn, id).await?)), + false => Err(AuthError::InvalidCredentials), + }, + None => { + let _ = User::match_hash_password(DUMMY_PASSWORD, &DUMMY_PASSWORD_PHC)?; + Err(AuthError::InvalidCredentials) + } + } + } + None => { let _ = User::match_hash_password(DUMMY_PASSWORD, &DUMMY_PASSWORD_PHC)?; Err(AuthError::InvalidCredentials) } } } -fn authenticate_bearer(token: &str) -> Result, AuthError> { - let conn = database::conn().map_err(|e| DatabaseError::from(e))?; - let mut s = Session::get_by_token(&conn, token)?; +async fn authenticate_bearer( + conn: &mut PgConnection, + token: &str, +) -> Result, AuthError> { + let mut s = Session::get_by_token(&mut *conn, token).await?; if s.is_expired_or_revoked() { return Err(AuthError::InvalidCredentials); } - s.prolong(&conn)?; - Ok(Some(User::get_by_id(&conn, s.user_id)?)) + s.prolong(&mut *conn).await?; + Ok(Some(User::get_by_id(conn, s.user_id).await?)) } -fn authenticate_bearer_with_session(token: &str) -> Result, AuthError> { - let conn = database::conn().map_err(|e| DatabaseError::from(e))?; - let mut s = Session::get_by_token(&conn, token)?; + +async fn authenticate_bearer_with_session( + conn: &mut PgConnection, + token: &str, +) -> Result, AuthError> { + let mut s = Session::get_by_token(&mut *conn, token).await?; if s.is_expired_or_revoked() { return Err(AuthError::InvalidCredentials); } - s.prolong(&conn)?; + s.prolong(conn).await?; Ok(Some(s)) } diff --git a/src/users/auth/mod.rs b/src/users/auth/mod.rs index c6432d4..43ac04d 100644 --- a/src/users/auth/mod.rs +++ b/src/users/auth/mod.rs @@ -16,14 +16,22 @@ pub mod implementation; pub const COOKIE_NAME: &str = "mnemohash"; +use sqlx::PgConnection; + pub trait UserAuthenticate { - fn authenticate(headers: &HeaderMap) -> Result, AuthError>; + async fn authenticate( + conn: &mut PgConnection, + headers: &HeaderMap, + ) -> Result, AuthError>; } pub trait UserAuthRequired { fn required(self) -> Result; } pub trait SessionAuthenticate { - fn authenticate(headers: &HeaderMap) -> Result, AuthError>; + async fn authenticate( + conn: &mut PgConnection, + headers: &HeaderMap, + ) -> Result, AuthError>; } pub trait SessionAuthRequired { fn required(self) -> Result; diff --git a/src/users/handle.rs b/src/users/handle.rs index 4bbf872..448c6f1 100644 --- a/src/users/handle.rs +++ b/src/users/handle.rs @@ -1,12 +1,9 @@ use std::{fmt::Display, hash::Hash, ops::Deref, str::FromStr}; -use rusqlite::{ - Result as RusqliteResult, - types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef}, -}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] +#[sqlx(transparent)] #[serde(into = "String")] #[serde(try_from = "String")] pub struct UserHandle(String); @@ -90,15 +87,3 @@ impl From for String { value.0 } } - -impl ToSql for UserHandle { - fn to_sql(&self) -> RusqliteResult> { - self.0.to_sql() - } -} - -impl FromSql for UserHandle { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - UserHandle::from_str(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))) - } -} diff --git a/src/users/mod.rs b/src/users/mod.rs index 633caec..75e6dff 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -3,8 +3,8 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::{DateTime, NaiveDate}; -use rusqlite::{Connection, OptionalExtension, ffi::SQLITE_CONSTRAINT_UNIQUE}; use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::{ @@ -45,65 +45,87 @@ pub enum UserError { } impl User { - pub fn total_count(conn: &Connection) -> Result { - Ok(conn.query_row("SELECT COUNT(*) FROM users", (), |r| r.get(0))?) + pub async fn total_count(conn: &mut PgConnection) -> Result { + Ok(sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(conn) + .await?) } - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let res = conn - .prepare("SELECT handle FROM users WHERE id = ?1")? - .query_one((&id,), |r| { + + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let res = sqlx::query("SELECT handle FROM users WHERE id = $1") + .bind(id) + .fetch_optional(conn) + .await?; + + match res { + Some(r) => { + let handle_str: String = r.try_get("handle")?; Ok(User { id, - handle: r.get(0)?, + handle: UserHandle::new(&handle_str)?, }) - }) - .optional()?; - match res { - Some(u) => Ok(u), + } None => Err(UserError::NoUserWithId(id)), } } - pub fn get_by_handle(conn: &Connection, handle: UserHandle) -> Result { - let res = conn - .prepare("SELECT id, handle FROM users WHERE handle = ?1")? - .query_one((&handle,), |r| { - Ok(User { - id: r.get(0)?, - handle: r.get(1)?, - }) - }) - .optional()?; + + pub async fn get_by_handle( + conn: &mut PgConnection, + handle: UserHandle, + ) -> Result { + let res = sqlx::query("SELECT id FROM users WHERE handle = $1") + .bind(handle.as_str()) + .fetch_optional(conn) + .await?; + match res { - Some(u) => Ok(u), + Some(r) => Ok(User { + id: r.try_get("id")?, + handle, + }), None => Err(UserError::NoUserWithHandle(handle)), } } - pub fn get_all(conn: &Connection) -> Result, UserError> { - Ok(conn - .prepare("SELECT id, handle FROM users")? - .query_map((), |r| { - Ok(User { - id: r.get(0)?, - handle: r.get(1)?, - }) - })? - .collect::, _>>()?) + + pub async fn get_all(conn: &mut PgConnection) -> Result, UserError> { + let rows = sqlx::query("SELECT id, handle FROM users") + .fetch_all(conn) + .await?; + + let mut users = Vec::with_capacity(rows.len()); + for r in rows { + let handle_str: String = r.try_get("handle")?; + users.push(User { + id: r.try_get("id")?, + handle: UserHandle::new(&handle_str)?, + }); + } + + Ok(users) } - pub fn create(conn: &Connection, handle: UserHandle) -> Result { + pub async fn create(conn: &mut PgConnection, handle: UserHandle) -> Result { let id = Uuid::now_v7(); - conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? - .execute((&id, &handle))?; + sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)") + .bind(id) + .bind(handle.as_str()) + .execute(conn) + .await?; + Ok(User { id, handle }) } - pub fn set_handle( + pub async fn set_handle( &mut self, - conn: &Connection, + conn: &mut PgConnection, new_handle: UserHandle, ) -> Result<(), UserError> { - conn.prepare("UPDATE users SET handle = ?1 WHERE id = ?2")? - .execute((&new_handle, self.id))?; + sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") + .bind(new_handle.as_str()) + .bind(self.id) + .execute(conn) + .await?; + self.handle = new_handle; Ok(()) } @@ -118,21 +140,26 @@ impl User { // DANGEROUS: AUTH impl User { - pub fn set_password( + pub async fn set_password( &mut self, - conn: &Connection, + conn: &mut PgConnection, passw: Option<&str>, ) -> Result<(), UserError> { match passw { None => { - conn.prepare("UPDATE users SET password = NULL WHERE id = ?1")? - .execute((self.id,))?; + sqlx::query("UPDATE users SET password = NULL WHERE id = $1") + .bind(self.id) + .execute(conn) + .await?; Ok(()) } Some(passw) => { let hashed = User::hash_password(passw)?; - conn.prepare("UPDATE users SET password = ?1 WHERE id = ?2")? - .execute((hashed, self.id))?; + sqlx::query("UPDATE users SET password = $1 WHERE id = $2") + .bind(hashed) + .bind(self.id) + .execute(conn) + .await?; Ok(()) } } @@ -148,14 +175,18 @@ impl User { /// to do everything and probably should not be used as a regular account /// due to the ramifications of compromise. But it could be used for that, /// and have its name changed. - pub fn create_infradmin(conn: &Connection) -> Result { + pub async fn create_infradmin(conn: &mut PgConnection) -> Result { let mut u = User { id: Uuid::max(), handle: UserHandle::new("Infradmin")?, }; - conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? - .execute((&u.id, &u.handle))?; - u.regenerate_infradmin_password(conn)?; + sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)") + .bind(u.id) + .bind(u.handle.as_str()) + .execute(&mut *conn) + .await?; + + u.regenerate_infradmin_password(conn).await?; Ok(u) } @@ -178,9 +209,12 @@ impl User { /// to do everything and probably should not be used as a regular account /// due to the ramifications of compromise. But it could be used for that, /// and have its name changed. - pub fn regenerate_infradmin_password(&mut self, conn: &Connection) -> Result<(), UserError> { + pub async fn regenerate_infradmin_password( + &mut self, + conn: &mut PgConnection, + ) -> Result<(), UserError> { let passw = auth::generate_token(auth::TokenSize::Char16); - self.set_password(conn, Some(&passw))?; + self.set_password(conn, Some(&passw)).await?; log::info!("[USERS] The infradmin account password has been (re)generated."); log::info!("[USERS] Handle: {}", self.handle.as_str()); log::info!("[USERS] Password: {}", passw); @@ -194,13 +228,16 @@ impl User { /// for actions performed by Mnemosyne internally. /// It shall not be available for log-in. /// It should not have its name changed, and should be protected from that. - pub fn create_systemuser(conn: &Connection) -> Result { + pub async fn create_systemuser(conn: &mut PgConnection) -> Result { let u = User { id: Uuid::nil(), handle: UserHandle::new("Mnemosyne")?, }; - conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? - .execute((&u.id, &u.handle))?; + sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)") + .bind(u.id) + .bind(u.handle.as_str()) + .execute(conn) + .await?; Ok(u) } @@ -216,22 +253,24 @@ impl User { } } -impl From for UserError { - fn from(error: rusqlite::Error) -> Self { - if let rusqlite::Error::SqliteFailure(err, Some(msg)) = &error - && err.extended_code == SQLITE_CONSTRAINT_UNIQUE - && msg.contains("handle") - { - return UserError::HandleAlreadyExists; +impl From for UserError { + fn from(error: sqlx::Error) -> Self { + if let sqlx::Error::Database(err) = &error { + // Check for Postgres unique constraint violation (code 23505) + if err.is_unique_violation() && err.message().contains("handle") { + return UserError::HandleAlreadyExists; + } } UserError::DatabaseError(DatabaseError::from(error)) } } + impl From for UserError { fn from(err: argon2::password_hash::Error) -> Self { UserError::PassHashError(err) } } + impl IntoResponse for UserError { fn into_response(self) -> Response { match self { diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 0f9fc1e..f24db99 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use sqlx::PgConnection; use crate::{database::DatabaseError, users::User}; @@ -17,13 +17,14 @@ pub enum Permission { RenameTags, DeleteTags, ChangePersonPrimaryName, + #[allow(unused)] BrowseServerLogs, } impl User { - pub fn has_permission( + pub async fn has_permission( &self, - #[allow(unused)] conn: &Connection, + #[allow(unused)] conn: &mut PgConnection, #[allow(unused)] permission: Permission, ) -> Result { // Infradmin and systemuser have all permissions diff --git a/src/users/sessions.rs b/src/users/sessions.rs index bf5b01a..e3ab9e3 100644 --- a/src/users/sessions.rs +++ b/src/users/sessions.rs @@ -3,9 +3,9 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::{DateTime, Duration, Utc}; -use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use sqlx::{PgConnection, Row}; use uuid::Uuid; use crate::{ @@ -46,11 +46,13 @@ pub enum SessionError { #[error("No session found with provided token")] NoSessionWithToken(String), } -impl From for SessionError { - fn from(error: rusqlite::Error) -> Self { + +impl From for SessionError { + fn from(error: sqlx::Error) -> Self { SessionError::DatabaseError(DatabaseError::from(error)) } } + impl IntoResponse for SessionError { fn into_response(self) -> Response { match self { @@ -70,55 +72,89 @@ impl IntoResponse for SessionError { } impl Session { - pub fn get_by_id(conn: &Connection, id: Uuid) -> Result { - let res = conn - .prepare("SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = ?1")? - .query_one((&id,), |r| Ok(Session { - id, - user_id: r.get(0)?, - expiry: r.get(1)?, - status: match r.get::<_, bool>(2)? { - false => SessionStatus::Active, - true => { - SessionStatus::Revoked { revoked_at: r.get(3)?, revoked_by: r.get(4)? } - } - } - })).optional()?; + pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result { + let row = sqlx::query( + "SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = $1", + ) + .bind(id) + .fetch_optional(conn) + .await?; - match res { - Some(s) => Ok(s), + match row { + Some(r) => { + let revoked: bool = r.try_get("revoked")?; + let status = if revoked { + SessionStatus::Revoked { + revoked_at: r.try_get("revoked_at")?, + revoked_by: r.try_get("revoked_by")?, + } + } else { + SessionStatus::Active + }; + + Ok(Session { + id, + user_id: r.try_get("user_id")?, + expiry: r.try_get("expiry")?, + status, + }) + } None => Err(SessionError::NoSessionWithId(id)), } } - pub fn get_by_token(conn: &Connection, token: &str) -> Result { - let hashed = Sha256::digest(token.as_bytes()).to_vec(); - let res = conn - .prepare("SELECT id, user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE token = ?1")? - .query_one((hashed,), |r| Ok(Session { - id: r.get(0)?, - user_id: r.get(1)?, - expiry: r.get(2)?, - status: match r.get::<_, bool>(3)? { - false => SessionStatus::Active, - true => { - SessionStatus::Revoked { revoked_at: r.get(4)?, revoked_by: r.get(5)? } - } - } - })).optional()?; - match res { - Some(s) => Ok(s), + pub async fn get_by_token( + conn: &mut PgConnection, + token: &str, + ) -> Result { + let hashed = Sha256::digest(token.as_bytes()).to_vec(); + let row = sqlx::query( + "SELECT id, user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE token = $1", + ) + .bind(&hashed) + .fetch_optional(conn) + .await?; + + match row { + Some(r) => { + let revoked: bool = r.try_get("revoked")?; + let status = if revoked { + SessionStatus::Revoked { + revoked_at: r.try_get("revoked_at")?, + revoked_by: r.try_get("revoked_by")?, + } + } else { + SessionStatus::Active + }; + + Ok(Session { + id: r.try_get("id")?, + user_id: r.try_get("user_id")?, + expiry: r.try_get("expiry")?, + status, + }) + } None => Err(SessionError::NoSessionWithToken(token.to_string())), } } - pub fn new_for_user(conn: &Connection, user: &User) -> Result<(Session, String), SessionError> { + + pub async fn new_for_user( + conn: &mut PgConnection, + user: &User, + ) -> Result<(Session, String), SessionError> { let id = Uuid::now_v7(); let token = auth::generate_token(auth::TokenSize::Char64); let hashed = Sha256::digest(token.as_bytes()).to_vec(); let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; - conn.prepare("INSERT INTO sessions(id, token, user_id, expiry) VALUES (?1, ?2, ?3, ?4)")? - .execute((&id, &hashed, user.id, expiry))?; + sqlx::query("INSERT INTO sessions(id, token, user_id, expiry) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(hashed) + .bind(user.id) + .bind(expiry) + .execute(conn) + .await?; + let s = Session { id, user_id: user.id, @@ -130,7 +166,8 @@ impl Session { pub const DEFAULT_PROLONGATION: Duration = Duration::days(14); const PROLONGATION_THRESHOLD: Duration = Duration::hours(2); - pub fn prolong(&mut self, conn: &Connection) -> Result<(), SessionError> { + + pub async fn prolong(&mut self, conn: &mut PgConnection) -> Result<(), SessionError> { if self.expiry - Session::DEFAULT_PROLONGATION + Session::PROLONGATION_THRESHOLD > Utc::now() { @@ -138,22 +175,37 @@ impl Session { } let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; - conn.prepare("UPDATE sessions SET expiry = ?1 WHERE id = ?2")? - .execute((&expiry, &self.id))?; + sqlx::query("UPDATE sessions SET expiry = $1 WHERE id = $2") + .bind(expiry) + .bind(self.id) + .execute(conn) + .await?; + self.expiry = expiry; Ok(()) } - pub fn revoke(&mut self, conn: &Connection, actor: Option<&User>) -> Result<(), SessionError> { + pub async fn revoke( + &mut self, + conn: &mut PgConnection, + actor: Option<&User>, + ) -> Result<(), SessionError> { let now = Utc::now(); - let id = actor.map(|u| u.id).unwrap_or(Uuid::nil()); - conn.prepare( - "UPDATE sessions SET revoked = ?1, revoked_at = ?2, revoked_by = ?3 WHERE id = ?4", - )? - .execute((&true, &now, &id, &self.id))?; + let actor_id = actor.map(|u| u.id).unwrap_or(Uuid::nil()); + + sqlx::query( + "UPDATE sessions SET revoked = $1, revoked_at = $2, revoked_by = $3 WHERE id = $4", + ) + .bind(true) + .bind(now) + .bind(actor_id) + .bind(self.id) + .execute(conn) + .await?; + self.status = SessionStatus::Revoked { revoked_at: now, - revoked_by: id, + revoked_by: actor_id, }; Ok(()) } @@ -165,9 +217,11 @@ impl Session { let timestamp = self.id.get_timestamp().unwrap().to_unix(); DateTime::from_timestamp_secs(timestamp.0 as i64).unwrap() } + pub fn is_expired_or_revoked(&self) -> bool { self.is_expired() || self.status.is_revoked() } + pub fn is_expired(&self) -> bool { self.expiry <= Utc::now() } diff --git a/src/users/setup.rs b/src/users/setup.rs index a213d33..065b83c 100644 --- a/src/users/setup.rs +++ b/src/users/setup.rs @@ -1,40 +1,37 @@ -use rusqlite::OptionalExtension; +use sqlx::PgPool; use uuid::Uuid; use crate::{ - database, logs::{LogAction, LogEntry}, users::{User, UserError}, }; -pub fn initialise_reserved_users_if_needed() -> Result<(), UserError> { - let mut conn = database::conn()?; - let tx = conn.transaction()?; +pub async fn initialise_reserved_users_if_needed(pool: &PgPool) -> Result<(), UserError> { + let mut tx = pool.begin().await?; - if tx - .prepare("SELECT handle FROM users WHERE id = ?1")? - .query_one((&Uuid::nil(),), |_| Ok(())) - .optional()? - .is_none() - { - let u = User::create_systemuser(&tx)?; - LogEntry::new(&tx, u, LogAction::Initialize)?; + let systemuser_exists = sqlx::query("SELECT handle FROM users WHERE id = $1") + .bind(Uuid::nil()) + .fetch_optional(&mut *tx) + .await? + .is_some(); + + if !systemuser_exists { + let u = User::create_systemuser(&mut *tx).await?; + LogEntry::new(&mut *tx, u, LogAction::Initialize).await?; } - if tx - .prepare("SELECT handle FROM users WHERE id = ?1")? - .query_one((&Uuid::max(),), |_| Ok(())) - .optional()? - .is_none() - { - User::create_infradmin(&tx)?; - LogEntry::new( - &tx, - User::get_by_id(&tx, Uuid::nil())?, - LogAction::RegenInfradmin, - )?; + let infradmin_exists = sqlx::query("SELECT handle FROM users WHERE id = $1") + .bind(Uuid::max()) + .fetch_optional(&mut *tx) + .await? + .is_some(); + + if !infradmin_exists { + User::create_infradmin(&mut *tx).await?; + let u = User::get_by_id(&mut *tx, Uuid::max()).await?; + LogEntry::new(&mut *tx, u, LogAction::RegenInfradmin).await?; } - tx.commit()?; + tx.commit().await?; Ok(()) } diff --git a/src/web/components/quote.rs b/src/web/components/quote.rs index 6f159a3..2ca5166 100644 --- a/src/web/components/quote.rs +++ b/src/web/components/quote.rs @@ -9,7 +9,9 @@ pub fn quote(quote: &Quote) -> Markup { (PreEscaped(icons::QUOTE)) } @for (i, line) in quote.lines.iter().enumerate() { - @let show_author = i == quote.lines.len()-1 || quote.lines[i+1].attribution.id != line.attribution.id; + @let is_last = i == quote.lines.len() - 1; + @let show_author = is_last || !line.attribution.iter().map(|a| a.id) + .eq(quote.lines[i + 1].attribution.iter().map(|a| a.id)); div class="mb-2" { span class="flex flex-row gap-2 relative" { span class="scale-x-[.65] scale-y-[.5] absolute opacity-[.3]"{ @@ -19,7 +21,7 @@ pub fn quote(quote: &Quote) -> Markup { } @if show_author { p class="text-sm italic ml-3 flex flex-row gap-1.5 text-neutral-400" { - "— " (line.attribution.name) + "— " (line.attribution.iter().map(|a| a.name.clone()).collect::>().join(", ")) } } } diff --git a/src/web/icons/git-commit-vertical.svg b/src/web/icons/git-commit-vertical.svg new file mode 100644 index 0000000..f944d60 --- /dev/null +++ b/src/web/icons/git-commit-vertical.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/line-dot-right-horizontal.svg b/src/web/icons/line-dot-right-horizontal.svg new file mode 100644 index 0000000..bc5e3c6 --- /dev/null +++ b/src/web/icons/line-dot-right-horizontal.svg @@ -0,0 +1 @@ + diff --git a/src/web/icons/mod.rs b/src/web/icons/mod.rs index b7c47f3..a708e20 100644 --- a/src/web/icons/mod.rs +++ b/src/web/icons/mod.rs @@ -8,8 +8,10 @@ pub const CLOCK: &str = include_str!("clock.svg"); pub const CONTACT: &str = include_str!("contact.svg"); pub const EYE: &str = include_str!("eye.svg"); pub const FILE_IMAGE: &str = include_str!("file-image.svg"); +pub const GIT_COMMIT_VERTICAL: &str = include_str!("git-commit-vertical.svg"); pub const INFO: &str = include_str!("info.svg"); pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg"); +pub const LINE_DOT_RIGHT_HORIZONTAL: &str = include_str!("line-dot-right-horizontal.svg"); pub const LOG_OUT: &str = include_str!("log-out.svg"); pub const MAP_PIN: &str = include_str!("map-pin.svg"); pub const PEN: &str = include_str!("pen.svg"); diff --git a/src/web/mod.rs b/src/web/mod.rs index 4c73856..9daa121 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,17 +1,14 @@ -use axum::{ - Router, - http::header, - response::{IntoResponse, Redirect, Response}, - routing::get, -}; +use axum::{Router, http::header, routing::get}; + +use crate::MnemoState; mod components; -mod icons; +pub mod icons; mod pages; pub const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/styles.css")); -pub fn web_router() -> Router { +pub fn web_router() -> Router { Router::new() .route( "/styles.css", @@ -19,10 +16,3 @@ pub fn web_router() -> Router { ) .merge(pages::pages()) } - -pub struct RedirectViaError(Redirect); -impl IntoResponse for RedirectViaError { - fn into_response(self) -> Response { - self.0.into_response() - } -} diff --git a/src/web/pages/dashboard.rs b/src/web/pages/dashboard.rs index 66119ce..79cf8e5 100644 --- a/src/web/pages/dashboard.rs +++ b/src/web/pages/dashboard.rs @@ -1,9 +1,12 @@ -use axum::extract::Request; +use axum::{ + extract::{Request, State}, + response::{IntoResponse, Redirect, Response}, +}; use chrono::{DateTime, Utc}; -use maud::{Markup, PreEscaped, html}; +use maud::{PreEscaped, html}; use crate::{ - database::{self}, + MnemoState, error::CompositeError, persons::Person, quotes::Quote, @@ -21,19 +24,30 @@ const LINKS: &[(&str, &str, &str)] = &[ ("Add Person", "/persons", icons::CONTACT), ]; -pub async fn page(req: Request) -> Result { - let u = User::authenticate(req.headers()).ok().flatten(); - let conn = database::conn()?; +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { + Some(u) => Some(u), + None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), + }; let newest_quote = match u { - Some(_) => Quote::get_newest(&conn)?, - None => Quote::get_newest_public(&conn)?, + Some(_) => Quote::get_newest(&mut *conn).await?, + None => Quote::get_newest_public(&mut *conn).await?, }; let random_quote = match u { - Some(_) => Quote::get_random(&conn)?, + Some(_) => Quote::get_random(&mut *conn).await?, None => None, }; + let quote_count = Quote::total_count(&mut *conn).await; + let person_count = Person::total_count(&mut *conn).await; + let tag_count = Tag::total_count(&mut *conn).await; + let user_count = User::total_count(&mut *conn).await; + Ok(base( "Dashboard | Mnemosyne", html!( @@ -79,25 +93,25 @@ pub async fn page(req: Request) -> Result { } div class="mx-auto max-w-4xl px-2 mt-4 flex flex-row gap-2" { (chip(html!({ - @match Quote::total_count(&conn) { + @match quote_count { Ok(count) => {(count) " QUOTES TOTAL"}, Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"}, } }))) (chip(html!({ - @match Person::total_count(&conn) { + @match person_count { Ok(count) => {(count) " PERSONS TOTAL"}, Err(_) => span class="text-red-400" {"PERSON COUNT ERR"}, } }))) (chip(html!({ - @match Tag::total_count(&conn) { + @match tag_count { Ok(count) => {(count) " TAGS TOTAL"}, Err(_) => span class="text-red-400" {"TAG COUNT ERR"} } }))) (chip(html!({ - @match User::total_count(&conn) { + @match user_count { Ok(count) => {(count) " USERS TOTAL"}, Err(_) => span class="text-red-400" {"USER COUNT ERR"} } @@ -106,7 +120,7 @@ pub async fn page(req: Request) -> Result { div class="text-4xl xs:text-6xl sm:text-8xl text-neutral-800/25 mt-16 text-center font-semibold font-lora select-none" {"Mnemosyne"} ), - )) + ).into_response()) } fn format_time_ago(dt: DateTime) -> String { diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs index fc2e813..2580a38 100644 --- a/src/web/pages/index.rs +++ b/src/web/pages/index.rs @@ -1,7 +1,7 @@ use axum::response::{IntoResponse, Redirect, Response}; -use crate::users::auth::AuthError; +use crate::error::CompositeError; -pub async fn page() -> Result { +pub async fn page() -> Result { Ok(Redirect::to("/dashboard").into_response()) } diff --git a/src/web/pages/login.rs b/src/web/pages/login.rs index d2fb44a..9d689ff 100644 --- a/src/web/pages/login.rs +++ b/src/web/pages/login.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Query, Request}, + extract::{Query, Request, State}, response::{IntoResponse, Redirect, Response}, }; use maud::{PreEscaped, html}; @@ -7,11 +7,10 @@ use rand::seq::IndexedRandom; use serde::Deserialize; use crate::{ + MnemoState, config::REFERENCE_SPLASHES, - users::{ - User, - auth::{AuthError, UserAuthenticate}, - }, + error::CompositeError, + users::{User, auth::UserAuthenticate}, web::{components::marquee::marquee, icons, pages::base}, }; @@ -20,8 +19,13 @@ pub struct LoginMsg { msg: Option, } -pub async fn page(Query(q): Query, req: Request) -> Result { - let u = User::authenticate(req.headers())?; +pub async fn page( + State(state): State, + Query(q): Query, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = User::authenticate(&mut *conn, req.headers()).await?; if u.is_some() { return Ok(Redirect::to("/dashboard").into_response()); } diff --git a/src/web/pages/logs.rs b/src/web/pages/logs.rs index ab93cee..db85e9b 100644 --- a/src/web/pages/logs.rs +++ b/src/web/pages/logs.rs @@ -1,40 +1,42 @@ use axum::{ - extract::{Query, Request}, + extract::{Query, Request, State}, response::{IntoResponse, Redirect, Response}, }; use maud::{PreEscaped, html}; use serde::Deserialize; +use strum::IntoEnumIterator; use crate::{ - database::{self}, + MnemoState, error::CompositeError, - logs::LogEntry, + logs::{LogActionDiscriminant, LogEntry}, users::{User, auth::UserAuthenticate}, web::{components::nav::nav, icons, pages::base}, }; #[derive(Deserialize)] -pub struct PageQuery { +pub struct LogsPageQuery { page: Option, + action: Option, } pub async fn page( - Query(query): Query, + State(state): State, + Query(query): Query, req: Request, ) -> Result { - let u = match User::authenticate(req.headers())? { + let mut tx = state.pool.acquire().await?; + let u = match User::authenticate(&mut *tx, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let mut conn = database::conn()?; - let tx = conn.transaction()?; let page = query.page.unwrap_or(1).max(1); let per_page = 20; let offset = (page - 1) * per_page; - let logs = LogEntry::get_chronological_offset(&tx, offset, per_page)?; - let total_logs = LogEntry::total_count(&tx)?; + let logs = LogEntry::get_chronological_offset(&mut *tx, query.action, offset, per_page).await?; + let total_logs = LogEntry::count(&mut *tx, query.action).await?; let total_pages = (total_logs as f64 / per_page as f64).ceil() as i64; Ok(base( @@ -42,18 +44,31 @@ pub async fn page( html!( (nav(Some(&u), req.uri().path())) - @if true {//let Ok(true) = u.has_permission(&tx, Permission::BrowseServerLogs) { + @if true {//let Ok(true) = u.has_permission(&mut *tx, Permission::BrowseServerLogs) { div class="max-w-4xl mx-auto px-2" { div class="my-4" { p class="flex items-center gap-2" { span class="text-neutral-500" {(PreEscaped(icons::CLIPBOARD_CLOCK))} span class="text-2xl font-semibold font-lora" {"Logs"} } - p class="text-neutral-500 text-sm font-light" { - "Work in progress." + } +// abcdefghijklmnopqrstuvwxyz + div class="mb-4 flex flex-wrap gap-2 items-center justify-between" { + div class="text-sm text-neutral-400" { + "Showing " (total_logs) " logs" + } + select + class="bg-neutral-900 border border-neutral-200/25 rounded px-2 py-1 text-sm text-neutral-200" + onchange="window.location.search = this.value ? '?action=' + this.value : ''" + { + option value="" { "All Actions" } + @for action in LogActionDiscriminant::iter() { + @let act_str: &'static str = action.into(); + option value=(act_str) selected[query.action == Some(action)] { (action.human_readable()) } + } } } - div class="w-full border border-neutral-200/25 rounded grid grid-cols-[auto_auto_1fr]" { + div class="w-full overflow-x-auto border border-neutral-200/25 rounded grid grid-cols-[auto_auto_1fr]" { @for (txt, ico) in [("Timestamp", icons::CLOCK), ("Actor", icons::USER), ("Action", icons::PEN)] { div class="p-2 flex gap-1 font-semibold border-b border-neutral-200/25" { span class="text-neutral-500 scale-[.8]" {(PreEscaped(ico))} @@ -76,9 +91,10 @@ pub async fn page( div class="p-2 font-light" style=(s) {(log.data.get_humanreadable_payload())} } } - div class="flex justify-between items-center my-4 text-neutral-400" { + @let action_q = query.action.map(|a| { let s: &'static str = a.into(); format!("&action={s}") }).unwrap_or_default(); + div class="flex flex-wrap gap-2 justify-between items-center my-4 text-neutral-400" { @if page > 1 { - a href=(format!("/logs?page={}", (page - 1).max(1))) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/logs?page={}{}", (page - 1).max(1), action_q)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Previous" } } @else { @@ -86,11 +102,11 @@ pub async fn page( } span { - "Page " (page) " of " (total_pages) + "Page " (page) " of " (total_pages.max(1)) } @if page < total_pages { - a href=(format!("/logs?page={}", page + 1)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/logs?page={}{}", page + 1, action_q)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Next" } } @else { diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index 355765a..b7d352e 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -4,6 +4,8 @@ use axum::{ }; use maud::{DOCTYPE, Markup, html}; +use crate::MnemoState; + pub mod dashboard; pub mod index; pub mod login; @@ -15,7 +17,7 @@ pub mod tags; pub mod users; pub mod usersettings; -pub fn pages() -> Router { +pub fn pages() -> Router { Router::new() .route("/", get(index::page)) .route("/login", get(login::page)) diff --git a/src/web/pages/notfound.rs b/src/web/pages/notfound.rs index 5c4e6ee..164475c 100644 --- a/src/web/pages/notfound.rs +++ b/src/web/pages/notfound.rs @@ -1,14 +1,19 @@ -use axum::extract::Request; +use axum::extract::{Request, State}; use maud::{Markup, html}; use crate::{ + MnemoState, error::CompositeError, users::{User, auth::UserAuthenticate}, web::{components::nav::nav, pages::base}, }; -pub async fn page(req: Request) -> Result { - let u = User::authenticate(req.headers()).ok().flatten(); +pub async fn page(State(state): State, req: Request) -> Result { + let mut conn = state.pool.acquire().await?; + let u = User::authenticate(&mut *conn, req.headers()) + .await + .ok() + .flatten(); Ok(base( "Not Found | Mnemosyne", html!( diff --git a/src/web/pages/persons.rs b/src/web/pages/persons.rs index 15daa64..378fd54 100644 --- a/src/web/pages/persons.rs +++ b/src/web/pages/persons.rs @@ -1,6 +1,7 @@ use axum::{ Form, extract::Request, + extract::State, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; @@ -8,26 +9,38 @@ use maud::{PreEscaped, html}; use serde::Deserialize; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::Person, users::{ User, - auth::{AuthError, UserAuthRequired, UserAuthenticate}, + auth::{UserAuthRequired, UserAuthenticate}, }, web::{components::nav::nav, icons, pages::base}, }; pub mod profile; -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + + let total_count = Person::total_count(&mut *conn).await; + let persons_res = Person::get_all(&mut *conn).await; + + let mut person_counts = vec![]; + if let Ok(ref persons) = persons_res { + for p in persons { + person_counts.push(p.get_in_quote_count(&mut *conn).await); + } + } Ok(base( "Persons | Mnemosyne", @@ -40,30 +53,28 @@ pub async fn page(req: Request) -> Result { span class="text-2xl font-semibold font-lora" {"Persons"} } p class="text-neutral-500 text-sm font-light" { - @if let Ok(c) = Person::total_count(&tx) { + @if let Ok(c) = total_count { (c) " persons in total." } @else { "Could not get total person count." } } } - @if let Ok(persons) = Person::get_all(&tx) { + @if let Ok(persons) = persons_res { div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" { - @for person in &persons { + @for (idx, person) in persons.iter().enumerate() { a href={"/persons/"(person.id)} class="rounded px-4 py-2 bg-neutral-200/5 hover:bg-neutral-200/10 border border-neutral-200/25 hover:border-neutral-200/25 flex items-center" { span class="text-neutral-400 mr-1 scale-125" {"~"} span class="text-sm" {(person.primary_name)} div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {} div class="text-xs flex items-center" { ( - if let Ok(i) = person.get_in_quote_count(&tx) { + if let Ok(i) = person_counts[idx] { i.to_string() } else { "?".to_string() } ) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))} - // div class="ml-2" {} - // "4" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))} } } } @@ -72,7 +83,7 @@ pub async fn page(req: Request) -> Result { p class="text-center p-2" {"No persons yet."} } div class="mx-auto max-w-4xl mt-4 px-2" { - h3 class="font-lora font-semibold text-xl" {"Add new person"} + h3 class="font-lora font-semibold text-xl mb-1" {"Add new person"} form action="/persons/create" method="post" { label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"} div class="flex gap-2" { @@ -96,22 +107,23 @@ pub struct PersonNameForm { primary_name: String, } pub async fn create( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let p = Person::create(&tx, form.primary_name, u.id)?; + let p = Person::create(&mut *tx, form.primary_name, u.id).await?; LogEntry::new( - &tx, + &mut *tx, u, LogAction::CreatePerson { id: p.id, pname: p.primary_name, }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to("/persons").into_response()) } diff --git a/src/web/pages/persons/profile.rs b/src/web/pages/persons/profile.rs index 5094e8e..47ba165 100644 --- a/src/web/pages/persons/profile.rs +++ b/src/web/pages/persons/profile.rs @@ -1,6 +1,6 @@ use axum::{ Form, - extract::{Path, Request}, + extract::{Path, Request, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; @@ -9,29 +9,45 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - database, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::{Name, Person}, users::{ User, - auth::{AuthError, UserAuthRequired, UserAuthenticate}, + auth::{UserAuthRequired, UserAuthenticate}, }, web::{components::nav::nav, pages::base}, }; -pub async fn page(Path(id): Path, req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; - let p = Person::get_by_id(&conn, id); + let p = Person::get_by_id(&mut *conn, id).await; let title = match &p { Ok(p) => format!("~{} | Mnemosyne", p.primary_name), Err(_) => "Error! | Mnemosyne".into(), }; + let mut names_with_attribution = Vec::new(); + let mut names_ok = false; + if let Ok(ref person) = p { + if let Ok(names) = person.get_all_names(&mut *conn).await { + names_ok = true; + for name in names { + let attr = name.times_attributed(&mut *conn).await.unwrap_or(0); + names_with_attribution.push((name, attr)); + } + } + } + Ok(base( &title, html!( @@ -46,14 +62,14 @@ pub async fn page(Path(id): Path, req: Request) -> Result, Path(id): Path, headers: HeaderMap, Form(form): Form, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let p = Person::get_by_id(&tx, id)?; - let n = p.add_name(&tx, form.name, u.id)?; + let p = Person::get_by_id(&mut *tx, id).await?; + let n = p.add_name(&mut *tx, form.name, u.id).await?; LogEntry::new( - &tx, + &mut *tx, u, LogAction::AddPersonName { pid: p.id, @@ -111,28 +127,29 @@ pub async fn add_name( pn: p.primary_name, nn: n.name, }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response()) } pub async fn delete_name( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let n = Name::get_by_id(&tx, id)?; - let p = Person::get_by_id(&tx, n.person_id)?; + let n = Name::get_by_id(&mut *tx, id).await?; + let p = Person::get_by_id(&mut *tx, n.person_id).await?; let nn = n.name.clone(); - n.delete(&tx)?; + n.delete(&mut *tx).await?; LogEntry::new( - &tx, + &mut *tx, u, LogAction::DeletePersonName { pid: p.id, @@ -140,8 +157,9 @@ pub async fn delete_name( pn: p.primary_name, n: nn, }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response()) } diff --git a/src/web/pages/quotes.rs b/src/web/pages/quotes.rs index 58edfaa..b94c7a2 100644 --- a/src/web/pages/quotes.rs +++ b/src/web/pages/quotes.rs @@ -1,12 +1,12 @@ use axum::{ - extract::{Query, Request}, + extract::{Query, Request, State}, response::{IntoResponse, Redirect, Response}, }; use maud::{PreEscaped, html}; use serde::Deserialize; use crate::{ - database, + MnemoState, error::CompositeError, quotes::Quote, users::{User, auth::UserAuthenticate}, @@ -22,26 +22,41 @@ pub mod add; #[derive(Deserialize)] pub struct PageQuery { page: Option, + s: Option, } pub async fn page( + State(state): State, Query(query): Query, req: Request, ) -> Result { - let u = match User::authenticate(req.headers())? { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; let page = query.page.unwrap_or(1).max(1); let per_page = 10; let offset = (page - 1) * per_page; - let quotes = Quote::get_chronological_offset(&conn, offset, per_page)?; - let total_quotes = Quote::total_count(&conn)?; + let search = query.s.as_deref().unwrap_or(""); + let quotes = match search { + "" => Quote::get_chronological_offset(&mut *conn, offset, per_page).await?, + _ => Quote::get_by_search_query(&mut *conn, search, offset, per_page).await?, + }; + let total_quotes = match search { + "" => Quote::total_count(&mut *conn).await?, + _ => Quote::search_query_count(&mut *conn, search).await?, + }; let total_pages = (total_quotes as f64 / per_page as f64).ceil() as i64; + let s_qs = if search.is_empty() { + String::new() + } else { + format!("&s={}", search) + }; + Ok(base( "Quotes | Mnemosyne", html!( @@ -58,8 +73,10 @@ pub async fn page( span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"} } } - input class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded" - placeholder="Search not yet implemented."; + form method="get" action="/quotes" { + input type="text" name="s" class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded" + placeholder="Search quotes..." value={(search)}; + } div class="my-2 w-full" { p class="ml-auto w-fit text-neutral-500 text-sm flex items-center" { span class="scale-[.6]" {(PreEscaped(icons::CALENDAR_ARROW_DOWN))} @@ -73,7 +90,7 @@ pub async fn page( div class="flex justify-between items-center mt-4 text-neutral-400" { @if page > 1 { - a href=(format!("/quotes?page={}", (page - 1).min(1))) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/quotes?page={}{}", (page - 1).max(1), s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Previous" } } @else { @@ -81,11 +98,11 @@ pub async fn page( } span { - "Page " (page) " of " (total_pages) + "Page " (page) " of " (total_pages.max(1)) } @if page < total_pages { - a href=(format!("/quotes?page={}", page + 1)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/quotes?page={}{}", page + 1, s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Next" } } @else { diff --git a/src/web/pages/quotes/add.rs b/src/web/pages/quotes/add.rs index 034a172..9cd6a9b 100644 --- a/src/web/pages/quotes/add.rs +++ b/src/web/pages/quotes/add.rs @@ -1,17 +1,16 @@ use axum::{ - extract::Request, + extract::{Request, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::Form; -use chrono::{TimeZone, Utc}; -use chrono_tz::Europe::Warsaw; +use chrono::NaiveDateTime; use maud::{Markup, PreEscaped, html}; use serde::Deserialize; use uuid::Uuid; use crate::{ - database, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, persons::Name, @@ -26,13 +25,16 @@ use crate::{ const LINE_ADD_RM_SCRIPT: &str = include_str!("line-add-rm.js"); const PREFILL_TIME_SCRIPT: &str = include_str!("prefill-time.js"); -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; - let names = Name::get_all(&conn)?; + let names = Name::get_all(&mut *conn).await?; Ok(base( "Add Quote | Mnemosyne", @@ -137,30 +139,32 @@ pub struct IncomingQuote { authors: Vec, location: String, time: String, - tz_offset: Option, context: String, } pub async fn form( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let authors = form - .authors + let mut authors = Vec::new(); + for nid in form.authors { + authors.push(Name::get_by_id(&mut *tx, nid).await.unwrap()); + } + let lines = form + .lines .into_iter() - .map(|nid| Name::get_by_id(&tx, nid).unwrap()); - let lines = form.lines.into_iter().zip(authors).collect(); - let offset = form - .tz_offset - .and_then(|mins| chrono::FixedOffset::west_opt(mins * 60)) - .unwrap_or_else(|| chrono::FixedOffset::west_opt(0).unwrap()); + .zip(authors) + .map(|(l, a)| (l, vec![a])) + .collect(); + + let timestamp = match NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M") { + Ok(ts) => ts, + Err(_) => return Ok("Time was formatted wrong.".into_response()), + }; - let timestamp = chrono::NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M") - .map(|ndt| offset.from_local_datetime(&ndt).unwrap()) - .unwrap_or_else(|_| Utc::now().with_timezone(&Warsaw).fixed_offset()); let context = match form.context.trim() { "" => None, s => Some(s.to_string()), @@ -170,9 +174,9 @@ pub async fn form( s => Some(s.to_string()), }; - let q = Quote::create(&tx, lines, timestamp, context, location, u.id, false)?; - LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?; - tx.commit()?; + let q = Quote::create(&mut *tx, lines, timestamp, context, location, u.id, false).await?; + LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?; + tx.commit().await?; Ok(Redirect::to("/dashboard").into_response()) } diff --git a/src/web/pages/tags.rs b/src/web/pages/tags.rs index fd23dc9..2dcda94 100644 --- a/src/web/pages/tags.rs +++ b/src/web/pages/tags.rs @@ -1,6 +1,6 @@ use axum::{ Form, - extract::{Path, Request}, + extract::{Path, Request, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; @@ -9,23 +9,39 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, tags::{Tag, TagName}, users::{ User, - auth::{AuthError, UserAuthRequired, UserAuthenticate}, + auth::{UserAuthRequired, UserAuthenticate}, }, web::{components::nav::nav, icons, pages::base}, }; -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; + let total_tags = Tag::total_count(&mut *conn).await; + let mut tags_with_counts = Vec::new(); + let tags = Tag::get_all(&mut *conn).await; + let mut is_tags_ok = false; + let mut is_tags_empty = true; + if let Ok(ts) = tags { + is_tags_ok = true; + is_tags_empty = ts.is_empty(); + for tag in ts { + let count = tag.get_tagged_quotes_count(&mut *conn).await; + tags_with_counts.push((tag, count)); + } + } Ok(base( "Tags | Mnemosyne", @@ -38,23 +54,23 @@ pub async fn page(req: Request) -> Result { span class="text-2xl font-semibold font-lora" {"Tags"} } p class="text-neutral-500 text-sm font-light" { - @if let Ok(c) = Tag::total_count(&conn) { + @if let Ok(c) = total_tags { (c) " tags in total." } @else { "Could not get total tag count." } } } - @if let Ok(tags) = Tag::get_all(&conn) { + @if is_tags_ok { div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" { - @for tag in &tags { + @for (tag, count) in tags_with_counts { div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" { span class="text-neutral-400 text-sm" {"#"} span class="text-sm" {(tag.name)} div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {} div class="text-xs flex items-center" { ( - if let Ok(i) = tag.get_tagged_quotes_count(&conn) { + if let Ok(i) = &count { i.to_string() } else { "?".to_string() @@ -63,7 +79,7 @@ pub async fn page(req: Request) -> Result { // div class="ml-2" {} // "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))} } - @if let Ok(0) = tag.get_tagged_quotes_count(&conn) { + @if let Ok(0) = count { form action=(format!("/tags/{}/delete", tag.id)) method="post" class="flex items-center ml-1" { button type="submit" class="text-neutral-500 hover:text-red-400 text-sm flex items-center justify-center cursor-pointer" title="Delete" { "✕" @@ -73,11 +89,11 @@ pub async fn page(req: Request) -> Result { } } } - @if tags.is_empty() { + @if is_tags_empty { p class="text-center p-2" {"No tags yet. How about making one?"} } div class="mx-auto max-w-4xl mt-4 px-2" { - h3 class="font-lora font-semibold text-xl" {"Add new tag"} + h3 class="font-lora font-semibold text-xl mb-1" {"Add new tag"} form action="/tags/create" method="post" { label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"} div class="flex gap-2" { @@ -101,40 +117,41 @@ pub struct TagForm { tagname: TagName, } pub async fn create( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let t = Tag::create(&tx, form.tagname)?; + let t = Tag::create(&mut *tx, form.tagname).await?; LogEntry::new( - &tx, + &mut *tx, u, LogAction::CreateTag { id: t.id, name: t.name.to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to("/tags").into_response()) } pub async fn delete_tag( + State(state): State, Path(id): Path, headers: HeaderMap, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - let t = Tag::get_by_id(&tx, id)?; + let t = Tag::get_by_id(&mut *tx, id).await?; let name = t.name.as_str().to_string(); - t.delete(&tx)?; + t.delete(&mut *tx).await?; - LogEntry::new(&tx, u, LogAction::DeleteTag { id, name })?; - tx.commit()?; + LogEntry::new(&mut *tx, u, LogAction::DeleteTag { id, name }).await?; + tx.commit().await?; Ok(Redirect::to("/tags").into_response()) } diff --git a/src/web/pages/users.rs b/src/web/pages/users.rs index 4436611..fd57570 100644 --- a/src/web/pages/users.rs +++ b/src/web/pages/users.rs @@ -1,16 +1,13 @@ use axum::{ - extract::Request, + extract::{Request, State}, response::{IntoResponse, Redirect, Response}, }; use maud::{PreEscaped, html}; use crate::{ - database, - users::{ - User, - auth::{AuthError, UserAuthenticate}, - permissions::Permission, - }, + MnemoState, + error::CompositeError, + users::{User, auth::UserAuthenticate, permissions::Permission}, web::{ components::{nav::nav, user_miniprofile::user_miniprofile}, icons, @@ -21,13 +18,19 @@ use crate::{ pub mod create; pub mod profile; -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; - let us = User::get_all(&conn); + let us = User::get_all(&mut *conn).await; + let can_create_users = u + .has_permission(&mut *conn, Permission::ManuallyCreateUsers) + .await; Ok(base( "Users | Mnemosyne", @@ -45,7 +48,7 @@ pub async fn page(req: Request) -> Result { } @else { "Could not fetch user count." } - @if let Ok(true) = u.has_permission(&conn, Permission::ManuallyCreateUsers) { + @if let Ok(true) = can_create_users { " " a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" { "Create a new user" diff --git a/src/web/pages/users/create.rs b/src/web/pages/users/create.rs index 116f0c6..5cba0c6 100644 --- a/src/web/pages/users/create.rs +++ b/src/web/pages/users/create.rs @@ -1,6 +1,6 @@ use axum::{ Form, - extract::Request, + extract::{Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, }; @@ -8,24 +8,30 @@ use maud::{PreEscaped, html}; use serde::Deserialize; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, users::{ User, - auth::{AuthError, UserAuthRequired, UserAuthenticate}, + auth::{UserAuthRequired, UserAuthenticate}, handle::UserHandle, permissions::Permission, }, web::{components::nav::nav, icons, pages::base}, }; -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let conn = database::conn()?; + let can_create = u + .has_permission(&mut *conn, Permission::ManuallyCreateUsers) + .await; Ok(base( "Create User | Mnemosyne", @@ -38,7 +44,7 @@ pub async fn page(req: Request) -> Result { span class="text-2xl font-semibold font-lora" {"Create a new user"} } } - @if let Ok(true) = u.has_permission(&conn, Permission::ManuallyCreateUsers) { + @if let Ok(true) = can_create { div class="mx-auto max-w-4xl px-2 mt-4" { form action="/users/create-form" method="post" class="flex flex-col" { label for="handle" class="font-light text-neutral-500" {"Handle"} @@ -68,26 +74,30 @@ pub struct CreateUserWithPasswordForm { password: String, } pub async fn create_user( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut *tx, &headers).await?.required()?; - if !u.has_permission(&tx, Permission::ManuallyCreateUsers)? { + if !u + .has_permission(&mut *tx, Permission::ManuallyCreateUsers) + .await? + { return Ok((StatusCode::FORBIDDEN).into_response()); } - let mut nu = User::create(&tx, form.handle)?; - nu.set_password(&tx, Some(&form.password))?; + let mut nu = User::create(&mut *tx, form.handle).await?; + nu.set_password(&mut *tx, Some(&form.password)).await?; LogEntry::new( - &tx, + &mut *tx, u, LogAction::CreateUser { id: nu.id, handle: nu.handle.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to("/users").into_response()) } diff --git a/src/web/pages/users/profile.rs b/src/web/pages/users/profile.rs index 1ebc59d..042dc9b 100644 --- a/src/web/pages/users/profile.rs +++ b/src/web/pages/users/profile.rs @@ -1,16 +1,13 @@ use axum::{ - extract::{Path, Request}, + extract::{Path, Request, State}, response::{IntoResponse, Redirect, Response}, }; -use chrono::{DateTime, Utc}; use maud::{PreEscaped, html}; use uuid::Uuid; use crate::{ - database::{self}, + MnemoState, error::CompositeError, - persons::Name, - quotes::{Quote, QuoteLine}, users::{User, UserError, auth::UserAuthenticate}, web::{ components::{nav::nav, quote::quote}, @@ -19,15 +16,18 @@ use crate::{ }, }; -pub async fn page(Path(id): Path, req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let mut tx = state.pool.acquire().await?; + let u = match User::authenticate(&mut *tx, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; - let mut conn = database::conn()?; - let tx = conn.transaction()?; - let user = match User::get_by_id(&tx, id) { + let user = match User::get_by_id(&mut *tx, id).await { Ok(u) => u, Err(UserError::NoUserWithId(_)) => { return Ok(base( @@ -65,7 +65,7 @@ pub async fn page(Path(id): Path, req: Request) -> Result, req: Request) -> Result Vec { - vec![ - Quote { - id: Uuid::now_v7(), - public: true, - location: Some(String::from("Poznań")), - context: Some(String::from("Wykład z językoznawstwa")), - created_by: Uuid::max(), - timestamp: DateTime::from(Utc::now()), - lines: vec![ - QuoteLine { - id: Uuid::now_v7(), - content: String::from("Nie wiem, czy są tutaj osoby fanowskie zipline-ów?"), - attribution: Name { - id: Uuid::nil(), - created_by: Uuid::max(), - person_id: Uuid::now_v7(), - is_primary: true, - name: String::from("dr. Barbara Konat"), - }, - }, - QuoteLine { - id: Uuid::now_v7(), - content: String::from("Taka uprząż co robi pziuuum!"), - attribution: Name { - id: Uuid::nil(), - created_by: Uuid::max(), - person_id: Uuid::now_v7(), - is_primary: true, - name: String::from("dr. Barbara Konat"), - }, - }, - ], - }, - Quote { - id: Uuid::now_v7(), - public: true, - location: Some(String::from("Discord VC")), - context: Some(String::from("O narysowanej dziewczynie")), - created_by: Uuid::max(), - timestamp: DateTime::from(Utc::now()), - lines: vec![ - QuoteLine { - id: Uuid::now_v7(), - content: String::from("Czy tu proporcje są zachowane?"), - attribution: Name { - id: Uuid::now_v7(), - created_by: Uuid::max(), - person_id: Uuid::now_v7(), - is_primary: true, - name: String::from("Adam"), - }, - }, - QuoteLine { - id: Uuid::now_v7(), - content: String::from("Adam, ona nie ma kolan."), - attribution: Name { - id: Uuid::nil(), - created_by: Uuid::max(), - person_id: Uuid::now_v7(), - is_primary: true, - name: String::from("Mollin"), - }, - }, - ], - }, - ] -} diff --git a/src/web/pages/usersettings.rs b/src/web/pages/usersettings.rs index 4bcd140..a7420a5 100644 --- a/src/web/pages/usersettings.rs +++ b/src/web/pages/usersettings.rs @@ -1,6 +1,6 @@ use axum::{ Form, - extract::Request, + extract::{Request, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; @@ -8,19 +8,23 @@ use maud::{PreEscaped, html}; use serde::Deserialize; use crate::{ - database::{self}, + MnemoState, error::CompositeError, logs::{LogAction, LogEntry}, users::{ User, - auth::{AuthError, UserAuthRequired, UserAuthenticate}, + auth::{UserAuthRequired, UserAuthenticate}, handle::UserHandle, }, web::{components::nav::nav, icons, pages::base}, }; -pub async fn page(req: Request) -> Result { - let u = match User::authenticate(req.headers())? { +pub async fn page( + State(state): State, + req: Request, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = match User::authenticate(&mut *conn, req.headers()).await? { Some(u) => u, None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), }; @@ -77,25 +81,26 @@ pub struct HandleForm { handle: UserHandle, } pub async fn change_handle( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let mut u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; + let mut tx = state.pool.begin().await?; + let mut u = User::authenticate(&mut *tx, &headers).await?.required()?; let oldhandle = u.handle.as_str().to_string(); - u.set_handle(&tx, form.handle)?; + u.set_handle(&mut *tx, form.handle).await?; LogEntry::new( - &tx, + &mut *tx, u.clone(), LogAction::ChangeUserHandle { id: u.id, old: oldhandle, new: u.handle.as_str().to_string(), }, - )?; - tx.commit()?; + ) + .await?; + tx.commit().await?; Ok(Redirect::to("/user-settings").into_response()) } @@ -104,13 +109,22 @@ pub struct PasswordForm { password: String, } pub async fn change_password( + State(state): State, headers: HeaderMap, Form(form): Form, ) -> Result { - let mut u = User::authenticate(&headers)?.required()?; - let mut conn = database::conn()?; - let tx = conn.transaction()?; - u.set_password(&tx, Some(&form.password))?; - tx.commit()?; + if form.password.trim().is_empty() { + return Ok(( + axum::http::StatusCode::BAD_REQUEST, + "Password cannot be empty or consist only of whitespace.", + ) + .into_response()); + } + + let mut tx = state.pool.begin().await?; + let mut u = User::authenticate(&mut *tx, &headers).await?.required()?; + + u.set_password(&mut *tx, Some(&form.password)).await?; + tx.commit().await?; Ok(Redirect::to("/user-settings").into_response()) }