From e91a019872a2f0bb2695d93f1ff2d60a93b0e151 Mon Sep 17 00:00:00 2001 From: emilis Date: Tue, 4 Nov 2025 22:25:50 +0000 Subject: [PATCH] wip persistence --- Cargo.lock | 904 +++++++++++++++++++++++++++++-- migrations/1_init.sql | 57 ++ werewolves-proto/Cargo.toml | 17 + werewolves-proto/src/cbor.rs | 156 ++++++ werewolves-proto/src/error.rs | 156 ++++++ werewolves-proto/src/game/mod.rs | 28 + werewolves-proto/src/id.rs | 1 + werewolves-proto/src/lib.rs | 98 +++- werewolves-proto/src/limited.rs | 129 +++++ werewolves-proto/src/player.rs | 6 + werewolves-proto/src/token.rs | 40 ++ werewolves-proto/src/user.rs | 14 + werewolves-server/Cargo.toml | 25 +- werewolves-server/src/db/game.rs | 150 +++++ werewolves-server/src/db/mod.rs | 40 ++ werewolves-server/src/db/user.rs | 214 ++++++++ werewolves-server/src/main.rs | 140 ++++- werewolves-server/src/runner.rs | 63 ++- werewolves/Trunk.toml | 7 +- 19 files changed, 2152 insertions(+), 93 deletions(-) create mode 100644 migrations/1_init.sql create mode 100644 werewolves-proto/src/cbor.rs create mode 100644 werewolves-proto/src/id.rs create mode 100644 werewolves-proto/src/limited.rs create mode 100644 werewolves-proto/src/token.rs create mode 100644 werewolves-proto/src/user.rs create mode 100644 werewolves-server/src/db/game.rs create mode 100644 werewolves-server/src/db/mod.rs create mode 100644 werewolves-server/src/db/user.rs diff --git a/Cargo.lock b/Cargo.lock index 20ead26..3ad96e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -47,6 +53,27 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atom_syndication" version = "0.12.7" @@ -79,7 +106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -129,13 +156,14 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.10.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +checksum = "5136e6c5e7e7978fe23e9876fb924af2c0f84c72127ac6ac17e7c46f457d362c" dependencies = [ "axum", "axum-core", "bytes", + "futures-core", "futures-util", "headers", "http 1.3.1", @@ -143,8 +171,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", - "serde_core", "tower-layer", "tower-service", "tracing", @@ -165,18 +191,18 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bincode" version = "1.3.3" @@ -195,6 +221,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -216,11 +251,20 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] [[package]] name = "cc" @@ -247,6 +291,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -287,6 +332,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "console_error_panic_hook" version = "0.1.7" @@ -297,6 +351,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.8.0" @@ -321,6 +381,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +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 = "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 = "crunchy" version = "0.2.4" @@ -378,6 +468,17 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[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 = "derive_builder" version = "0.20.2" @@ -422,7 +523,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -445,6 +548,21 @@ dependencies = [ "syn 2.0.106", ] +[[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 = "encoding_rs" version = "0.8.35" @@ -473,6 +591,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "fast_qr" version = "0.13.1" @@ -485,12 +625,29 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +[[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 = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -542,6 +699,17 @@ dependencies = [ "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.31" @@ -1051,19 +1219,39 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "headers" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http 1.3.1", @@ -1081,12 +1269,51 @@ dependencies = [ "http 1.3.1", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[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.1", +] + [[package]] name = "http" version = "0.2.12" @@ -1351,7 +1578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -1404,12 +1631,48 @@ 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 = "libc" version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.0" @@ -1438,6 +1701,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[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.7.6" @@ -1486,6 +1759,42 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" +[[package]] +name = "num-bigint-dig" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +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" @@ -1493,6 +1802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1520,6 +1830,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[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.4" @@ -1540,7 +1856,27 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", ] [[package]] @@ -1592,6 +1928,33 @@ dependencies = [ "thiserror 1.0.69", ] +[[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 = "potential_utf" version = "0.1.3" @@ -1725,14 +2088,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1742,7 +2126,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -1794,14 +2187,15 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ron" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ - "base64 0.21.7", + "base64", "bitflags", "serde", "serde_derive", + "unicode-ident", ] [[package]] @@ -1810,6 +2204,26 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -1933,6 +2347,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1948,6 +2373,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 = "slab" version = "0.4.11" @@ -1959,6 +2394,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1970,18 +2408,250 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "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", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[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 2.0.106", +] + +[[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 2.0.106", + "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 2.0.17", + "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 2.0.17", + "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 2.0.17", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[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]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2079,6 +2749,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.47.1" @@ -2133,6 +2818,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2161,11 +2859,27 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-stream", + "tokio-util", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "http 1.3.1", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2221,7 +2935,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand", + "rand 0.9.2", "sha1", "thiserror 2.0.17", "utf-8", @@ -2233,12 +2947,33 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[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.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[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-segmentation" version = "1.12.0" @@ -2287,6 +3022,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2317,6 +3058,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.100" @@ -2422,7 +3169,7 @@ dependencies = [ "instant", "log", "once_cell", - "rand", + "rand 0.9.2", "serde", "serde_json", "thiserror 2.0.17", @@ -2451,13 +3198,20 @@ dependencies = [ name = "werewolves-proto" version = "0.1.0" dependencies = [ + "argon2", + "axum", + "axum-extra", + "bytes", + "chrono", + "ciborium", "colored", "log", "pretty_assertions", "pretty_env_logger", - "rand", + "rand 0.9.2", "serde", "serde_json", + "sqlx", "thiserror 2.0.17", "uuid", "werewolves-macros", @@ -2468,6 +3222,7 @@ name = "werewolves-server" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "atom_syndication", "axum", "axum-extra", @@ -2480,16 +3235,29 @@ dependencies = [ "log", "mime-sniffer", "pretty_env_logger", - "rand", + "rand 0.9.2", "ron", "serde", "serde_json", + "sqlx", "thiserror 2.0.17", "tokio", + "tower", + "tower-http", "werewolves-macros", "werewolves-proto", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2558,13 +3326,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2576,34 +3353,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2616,24 +3426,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2802,6 +3636,12 @@ dependencies = [ "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.2" diff --git a/migrations/1_init.sql b/migrations/1_init.sql new file mode 100644 index 0000000..6a41efe --- /dev/null +++ b/migrations/1_init.sql @@ -0,0 +1,57 @@ +drop table if exists users cascade; +create table users ( + id uuid not null default gen_random_uuid() primary key, + name text, + username text not null, + password_hash text not null, + + created_at timestamp with time zone not null, + updated_at timestamp with time zone not null, + + check (created_at <= updated_at) +); +drop index if exists users_username_idx; +create index users_username_idx on users (username); +drop index if exists users_username_unique; +create unique index users_username_unique on users (lower(username)); + +drop table if exists login_tokens cascade; +create table login_tokens ( + token text not null primary key, + user_id uuid not null references users(id), + created_at timestamp with time zone not null, + expires_at timestamp with time zone not null, + + check (created_at < expires_at) +); + +drop type if exists game_outcome cascade; +create type game_outcome as enum ( + 'village_victory', + 'wolves_victory' +); + +drop table if exists games cascade; +create table games ( + id uuid not null primary key, + outcome game_outcome, + state json not null, + story json not null, + + started_at timestamp with time zone not null, + updated_at timestamp with time zone not null default now() +); + +drop table if exists players; +create table players ( + id uuid not null primary key, + user_id uuid references users(id) +); + +drop table if exists game_players; +create table game_players ( + game_id uuid not null references games(id), + player_id uuid not null references players(id), + + primary key (game_id, player_id) +); diff --git a/werewolves-proto/Cargo.toml b/werewolves-proto/Cargo.toml index 7984809..19a031c 100644 --- a/werewolves-proto/Cargo.toml +++ b/werewolves-proto/Cargo.toml @@ -11,8 +11,25 @@ serde = { version = "1.0", features = ["derive"] } uuid = { version = "1.17", features = ["v4", "serde"] } rand = { version = "0.9" } werewolves-macros = { path = "../werewolves-macros" } +axum = { version = "*", optional = true } +argon2 = { version = "*", optional = true } +sqlx = { version = "*", optional = true } +ciborium = { version = "*", optional = true } +bytes = { version = "1.10.1", features = ["serde"], optional = true } +axum-extra = { version = "*", optional = true } +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] pretty_assertions = { version = "1" } pretty_env_logger = { version = "0.5" } colored = { version = "3.0" } + +[features] +server = [ + "dep:axum", + "dep:sqlx", + "dep:argon2", + "dep:ciborium", + "dep:bytes", + "dep:axum-extra", +] diff --git a/werewolves-proto/src/cbor.rs b/werewolves-proto/src/cbor.rs new file mode 100644 index 0000000..bb6eab8 --- /dev/null +++ b/werewolves-proto/src/cbor.rs @@ -0,0 +1,156 @@ +use axum::{ + body::Bytes, + extract::{FromRequest, Request, rejection::BytesRejection}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use axum_extra::headers::Mime; +use bytes::{BufMut, BytesMut}; +use core::fmt::Display; +use serde::{Serialize, de::DeserializeOwned}; + +const CBOR_CONTENT_TYPE: &str = "application/cbor"; +const PLAIN_CONTENT_TYPE: &str = "text/plain"; + +#[must_use] +pub struct Cbor(pub T); + +impl Cbor { + pub const fn new(t: T) -> Self { + Self(t) + } +} + +impl FromRequest for Cbor +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = CborRejection; + + async fn from_request(req: Request, state: &S) -> Result { + if !cbor_content_type(req.headers()) { + return Err(CborRejection::MissingCborContentType); + } + + let bytes = Bytes::from_request(req, state).await?; + Ok(Self(ciborium::from_reader::(&*bytes)?)) + } +} + +impl IntoResponse for Cbor +where + T: Serialize, +{ + fn into_response(self) -> axum::response::Response { + // Extracted into separate fn so it's only compiled once for all T. + fn make_response(buf: BytesMut, ser_result: Result<(), CborRejection>) -> Response { + match ser_result { + Ok(()) => ( + [( + header::CONTENT_TYPE, + HeaderValue::from_static(CBOR_CONTENT_TYPE), + )], + buf.freeze(), + ) + .into_response(), + Err(err) => err.into_response(), + } + } + + // Use a small initial capacity of 128 bytes like serde_json::to_vec + // https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189 + let mut buf = BytesMut::with_capacity(128).writer(); + let res = ciborium::into_writer(&self.0, &mut buf) + .map_err(|err| CborRejection::SerdeRejection(err.to_string())); + make_response(buf.into_inner(), res) + } +} + +#[derive(Debug)] +pub enum CborRejection { + MissingCborContentType, + BytesRejection(BytesRejection), + DeserializeRejection(String), + SerdeRejection(String), +} +impl From> for CborRejection { + fn from(value: ciborium::de::Error) -> Self { + Self::SerdeRejection(match value { + ciborium::de::Error::Io(err) => format!("i/o: {err}"), + ciborium::de::Error::Syntax(offset) => format!("syntax error at {offset}"), + ciborium::de::Error::Semantic(offset, err) => format!( + "semantic parse: {err}{}", + offset + .map(|offset| format!(" at {offset}")) + .unwrap_or_default(), + ), + ciborium::de::Error::RecursionLimitExceeded => { + String::from("the input caused serde to recurse too much") + } + }) + } +} + +impl From for CborRejection { + fn from(value: BytesRejection) -> Self { + Self::BytesRejection(value) + } +} + +impl IntoResponse for CborRejection { + fn into_response(self) -> axum::response::Response { + match self { + CborRejection::MissingCborContentType => ( + StatusCode::BAD_REQUEST, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(PLAIN_CONTENT_TYPE), + )], + String::from("missing cbor content type"), + ), + CborRejection::BytesRejection(err) => ( + err.status(), + [( + header::CONTENT_TYPE, + HeaderValue::from_static(PLAIN_CONTENT_TYPE), + )], + format!("bytes rejection: {}", err.body_text()), + ), + CborRejection::SerdeRejection(err) => ( + StatusCode::BAD_REQUEST, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(PLAIN_CONTENT_TYPE), + )], + err, + ), + CborRejection::DeserializeRejection(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(PLAIN_CONTENT_TYPE), + )], + err, + ), + } + .into_response() + } +} + +fn cbor_content_type(headers: &HeaderMap) -> bool { + let Some(content_type) = headers.get(header::CONTENT_TYPE) else { + return false; + }; + + let Ok(content_type) = content_type.to_str() else { + return false; + }; + + let Ok(mime) = content_type.parse::() else { + return false; + }; + + mime.type_() == "application" + && (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor")) +} diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index 21c6b6f..d8c0bdc 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "server")] +use core::fmt::Display; + use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -81,4 +84,157 @@ pub enum GameError { MissingTime(GameTime), #[error("no previous during day")] NoPreviousDuringDay, + #[error("server error: {0}")] + ServerError(#[from] ServerError), +} + +#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)] +pub enum DatabaseError { + #[error("user already exists")] + UserAlreadyExists, + #[error("password hashing error: {0}")] + PasswordHashError(String), + #[error("sqlx error: {0}")] + SqlxError(String), + #[error("not found")] + NotFound, + #[error("serde_json: {0}")] + SerdeJson(String), +} + +#[cfg(feature = "server")] +impl From for DatabaseError { + fn from(value: serde_json::Error) -> Self { + Self::SerdeJson(value.to_string()) + } +} + +#[cfg(feature = "server")] +impl axum::response::IntoResponse for DatabaseError { + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + use crate::cbor::Cbor; + + ( + match self { + DatabaseError::UserAlreadyExists => StatusCode::BAD_REQUEST, + DatabaseError::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + Cbor(self), + ) + .into_response() + } +} + +#[cfg(feature = "server")] +impl From for DatabaseError { + fn from(err: sqlx::Error) -> Self { + match err { + sqlx::Error::RowNotFound => Self::NotFound, + _ => Self::SqlxError(err.to_string()), + } + } +} + +#[cfg(feature = "server")] +impl From for DatabaseError { + fn from(err: argon2::password_hash::Error) -> Self { + Self::PasswordHashError(err.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)] +pub enum ServerError { + #[error("database error: {0}")] + DatabaseError(DatabaseError), + #[error("invalid credentials")] + InvalidCredentials, + #[error("token expired")] + ExpiredToken, + #[error("internal server error: {0}")] + InternalServerError(String), + #[error("connection error")] + ConnectionError, + #[error("invalid request: {0}")] + InvalidRequest(String), + #[error("not found")] + NotFound, +} + +impl> From for ServerError { + fn from(value: I) -> Self { + let database_err: DatabaseError = value.into(); + if let DatabaseError::NotFound = &database_err { + return Self::NotFound; + } + Self::DatabaseError(database_err) + } +} + +impl From for ServerError { + fn from(value: GameError) -> Self { + Self::InvalidRequest(value.to_string()) + } +} + +#[cfg(feature = "server")] +impl From> for ServerError { + fn from(_: ciborium::de::Error) -> Self { + Self::InvalidRequest(String::from("could not decode request")) + } +} + +#[cfg(feature = "server")] +impl axum::response::IntoResponse for ServerError { + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + use crate::cbor::Cbor; + + match self { + ServerError::ExpiredToken => { + (StatusCode::UNAUTHORIZED, Cbor(ServerError::ExpiredToken)).into_response() + } + ServerError::NotFound | ServerError::DatabaseError(DatabaseError::NotFound) => { + (StatusCode::NOT_FOUND, Cbor(ServerError::NotFound)).into_response() + } + ServerError::DatabaseError(DatabaseError::UserAlreadyExists) => ( + StatusCode::BAD_REQUEST, + Cbor(ServerError::InvalidRequest(String::from("username taken"))), + ) + .into_response(), + ServerError::DatabaseError(err) => { + use uuid::Uuid; + + let error_id = Uuid::new_v4(); + log::error!("database error[{error_id}]: {err}"); + + ( + StatusCode::INTERNAL_SERVER_ERROR, + Cbor(ServerError::InternalServerError(format!( + "internal server error. error id: {error_id}" + ))), + ) + .into_response() + } + ServerError::InvalidCredentials => ( + StatusCode::UNAUTHORIZED, + Cbor(ServerError::InvalidCredentials), + ) + .into_response(), + ServerError::InternalServerError(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, Cbor(self)).into_response() + } + ServerError::ConnectionError => { + (StatusCode::BAD_REQUEST, Cbor(ServerError::ConnectionError)).into_response() + } + ServerError::InvalidRequest(reason) => ( + StatusCode::BAD_REQUEST, + Cbor(ServerError::InvalidRequest(reason)), + ) + .into_response(), + } + } } diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs index 37c63a8..0fc92f1 100644 --- a/werewolves-proto/src/game/mod.rs +++ b/werewolves-proto/src/game/mod.rs @@ -10,6 +10,7 @@ use core::{ ops::{Deref, Range, RangeBounds}, }; +use chrono::{DateTime, Utc}; use rand::{Rng, seq::SliceRandom}; use serde::{Deserialize, Serialize}; @@ -20,6 +21,7 @@ use crate::{ night::{Night, ServerAction}, story::{DayDetail, GameActions, GameStory, NightDetails}, }, + id::GameId, message::{ CharacterState, Identification, host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, @@ -36,6 +38,8 @@ type Result = core::result::Result; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Game { + id: GameId, + started_at: DateTime, history: GameStory, state: GameState, } @@ -44,12 +48,36 @@ impl Game { pub fn new(players: &[Identification], settings: GameSettings) -> Result { let village = Village::new(players, settings)?; Ok(Self { + id: GameId::new(), + started_at: Utc::now(), history: GameStory::new(village.clone()), state: GameState::Night { night: Night::new(village)?, }, }) } + #[cfg(feature = "server")] + pub const fn new_from_parts( + id: GameId, + started_at: DateTime, + history: GameStory, + state: GameState, + ) -> Self { + Self { + id, + started_at, + history, + state, + } + } + + pub const fn game_id(&self) -> GameId { + self.id + } + + pub const fn started_at(&self) -> DateTime { + self.started_at + } pub const fn village(&self) -> &Village { match &self.state { diff --git a/werewolves-proto/src/id.rs b/werewolves-proto/src/id.rs new file mode 100644 index 0000000..cf091b9 --- /dev/null +++ b/werewolves-proto/src/id.rs @@ -0,0 +1 @@ +crate::id_impl!(GameId); diff --git a/werewolves-proto/src/lib.rs b/werewolves-proto/src/lib.rs index 224d142..f890250 100644 --- a/werewolves-proto/src/lib.rs +++ b/werewolves-proto/src/lib.rs @@ -1,13 +1,109 @@ #![allow(clippy::new_without_default)] - +#[cfg(feature = "server")] +pub mod cbor; pub mod character; pub mod diedto; pub mod error; pub mod game; #[cfg(test)] mod game_test; +pub mod id; +pub mod limited; pub mod message; pub mod modifier; pub mod nonzero; pub mod player; pub mod role; +pub mod token; +pub mod user; + +#[macro_export] +macro_rules! id_impl { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + pub struct $name(uuid::Uuid); + + #[cfg(feature = "server")] + impl sqlx::TypeInfo for $name { + fn is_null(&self) -> bool { + self.0 == uuid::Uuid::nil() + } + + fn name(&self) -> &str { + "uuid" + } + } + + #[cfg(feature = "server")] + impl sqlx::Type for $name { + fn type_info() -> ::TypeInfo { + >::type_info() + } + } + + #[cfg(feature = "server")] + impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name { + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + self.0.encode_by_ref(buf) + } + } + + #[cfg(feature = "server")] + impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + Ok(Self(uuid::Uuid::decode(value)?)) + } + } + + impl From for $name { + fn from(value: uuid::Uuid) -> Self { + Self::from_uuid(value) + } + } + + impl From<$name> for uuid::Uuid { + fn from(value: $name) -> Self { + value.into_uuid() + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl $name { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + + pub const fn from_uuid(uuid: uuid::Uuid) -> Self { + Self(uuid) + } + + pub const fn into_uuid(self) -> uuid::Uuid { + self.0 + } + } + + impl core::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl core::str::FromStr for $name { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(uuid::Uuid::from_str(s)?)) + } + } + }; +} diff --git a/werewolves-proto/src/limited.rs b/werewolves-proto/src/limited.rs new file mode 100644 index 0000000..5b90efe --- /dev/null +++ b/werewolves-proto/src/limited.rs @@ -0,0 +1,129 @@ +use core::{ + fmt::Display, + ops::{Deref, RangeInclusive}, +}; + +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FixedLenString(String); + +impl Display for FixedLenString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl FixedLenString { + pub fn new(s: String) -> Option { + (s.chars().take(LEN + 1).count() == LEN).then_some(Self(s)) + } +} + +impl Deref for FixedLenString { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de, const LEN: usize> Deserialize<'de> for FixedLenString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ExpectedLen(usize); + impl serde::de::Expected for ExpectedLen { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "a string exactly {} characters long", self.0) + } + } + ::deserialize(deserializer).and_then(|s| { + let char_count = s.chars().take(LEN.saturating_add(1)).count(); + if char_count != LEN { + Err(serde::de::Error::invalid_length( + char_count, + &ExpectedLen(LEN), + )) + } else { + Ok(Self(s)) + } + }) + } +} + +impl Serialize for FixedLenString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ClampedString(String); + +impl ClampedString { + pub fn new(s: String) -> Result> { + let str_len = s.chars().take(MAX.saturating_add(1)).count(); + (str_len >= MIN && str_len <= MAX) + .then_some(Self(s)) + .ok_or(MIN..=MAX) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl Display for ClampedString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Deref for ClampedString { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de, const MIN: usize, const MAX: usize> Deserialize<'de> for ClampedString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ExpectedLen(usize, usize); + impl serde::de::Expected for ExpectedLen { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "a string between {} and {} characters long", + self.0, self.1 + ) + } + } + ::deserialize(deserializer).and_then(|s| { + let char_count = s.chars().take(MAX.saturating_add(1)).count(); + if char_count < MIN || char_count > MAX { + Err(serde::de::Error::invalid_length( + char_count, + &ExpectedLen(MIN, MAX), + )) + } else { + Ok(Self(s)) + } + }) + } +} + +impl Serialize for ClampedString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} diff --git a/werewolves-proto/src/player.rs b/werewolves-proto/src/player.rs index b734c2a..4b5bb6f 100644 --- a/werewolves-proto/src/player.rs +++ b/werewolves-proto/src/player.rs @@ -17,6 +17,12 @@ impl PlayerId { pub const fn from_u128(v: u128) -> Self { Self(uuid::Uuid::from_u128(v)) } + pub const fn from_uuid(v: uuid::Uuid) -> Self { + Self(v) + } + pub const fn into_uuid(self) -> uuid::Uuid { + self.0 + } } impl Display for PlayerId { diff --git a/werewolves-proto/src/token.rs b/werewolves-proto/src/token.rs new file mode 100644 index 0000000..0f32b72 --- /dev/null +++ b/werewolves-proto/src/token.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{limited::FixedLenString, user::Username}; + +pub const TOKEN_LEN: usize = 0x20; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Token { + pub token: FixedLenString, + pub username: Username, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl Token { + pub fn login_token(&self) -> TokenLogin { + TokenLogin(self.token.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TokenLogin(pub FixedLenString); + +#[cfg(feature = "server")] +impl axum_extra::headers::authorization::Credentials for TokenLogin { + const SCHEME: &'static str = "Bearer"; + + fn decode(value: &axum::http::HeaderValue) -> Option { + value + .to_str() + .ok() + .and_then(|v| FixedLenString::new(v.strip_prefix("Bearer ").unwrap_or(v).to_string())) + .map(Self) + } + + fn encode(&self) -> axum::http::HeaderValue { + axum::http::HeaderValue::from_str(self.0.as_str()).expect("bearer token encode") + } +} diff --git a/werewolves-proto/src/user.rs b/werewolves-proto/src/user.rs new file mode 100644 index 0000000..f7d2c79 --- /dev/null +++ b/werewolves-proto/src/user.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +use crate::limited::ClampedString; + +pub type Username = ClampedString<1, 0x40>; +pub type Password = ClampedString<6, 0x100>; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UserLogin { + pub username: Username, + pub password: Password, +} + +crate::id_impl!(UserId); diff --git a/werewolves-server/Cargo.toml b/werewolves-server/Cargo.toml index f5339fe..352a80c 100644 --- a/werewolves-server/Cargo.toml +++ b/werewolves-server/Cargo.toml @@ -11,12 +11,12 @@ pretty_env_logger = { version = "0.5" } # env_logger = { version = "0.11" } futures = "0.3.31" anyhow = { version = "1" } -werewolves-proto = { path = "../werewolves-proto" } +werewolves-proto = { path = "../werewolves-proto", features = ["server"] } werewolves-macros = { path = "../werewolves-macros" } mime-sniffer = { version = "0.1" } chrono = { version = "0.4" } atom_syndication = { version = "0.12" } -axum-extra = { version = "0.10", features = ["typed-header"] } +axum-extra = { version = "0.12", features = ["typed-header"] } rand = { version = "0.9" } serde_json = { version = "1.0" } serde = { version = "1.0", features = ["derive"] } @@ -24,8 +24,27 @@ thiserror = { version = "2" } ciborium = { version = "0.2", optional = true } colored = { version = "3.0" } fast_qr = { version = "0.13", features = ["svg"] } -ron = "0.8" +ron = "0.11" bytes = { version = "1.10" } +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "postgres", + "derive", + "macros", + "uuid", + "chrono", +] } +argon2 = { version = "0.5" } +tower-http = { version = "0.6", features = ["cors"] } +tower = { version = "0.5.2", features = [ + "limit", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "buffer", + "timeout", +] } [features] diff --git a/werewolves-server/src/db/game.rs b/werewolves-server/src/db/game.rs new file mode 100644 index 0000000..3fa9219 --- /dev/null +++ b/werewolves-server/src/db/game.rs @@ -0,0 +1,150 @@ +use sqlx::{Pool, Postgres, query}; +use werewolves_proto::{ + error::DatabaseError, + game::{Game, GameOver, story::GameStory}, + id::GameId, + player::PlayerId, + user::UserId, +}; + +#[derive(Debug, Clone)] +pub struct GameDatabase { + pub(super) pool: Pool, +} + +impl GameDatabase { + pub async fn new_game(&self, game: &Game) -> Result<(), DatabaseError> { + let state = serde_json::to_value(game.game_state())?; + let story = serde_json::to_value(&game.story())?; + + let mut tx = self.pool.begin().await?; + query!( + r#" insert into + games (id, started_at, state, story) + values + ($1, $2, $3, $4)"#, + game.game_id().into_uuid(), + game.started_at(), + state, + story, + ) + .execute(&mut *tx) + .await?; + + let player_ids = game + .village() + .characters() + .into_iter() + .map(|c| c.player_id().into_uuid()) + .collect::>(); + let user_ids = query!( + r#" select + id, user_id + from + players + where + id = any($1::uuid[])"#, + &*player_ids, + ) + .fetch_all(&mut *tx) + .await? + .into_iter() + .map(|r| (PlayerId::from_uuid(r.id), r.user_id.map(UserId::from_uuid))) + .unzip::, Vec, Vec>>(); + + let game_id = game.game_id().into_uuid(); + let game_ids = (0..player_ids.len()).map(|_| game_id).collect::>(); + + query!( + r#" with + game_ids as (select row_number() over(), * from unnest($1::uuid[]) as game_id), + player_ids as (select row_number() over(), * from unnest($2::uuid[]) as player_id) + insert into + game_players + select + game_ids.game_id, player_ids.player_id + from + game_ids + join + player_ids on game_ids.row_number = player_ids.row_number + "#, + &*game_ids, + &*player_ids, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn update_game(&self, game: &Game) -> Result<(), DatabaseError> { + let state = serde_json::to_value(game.game_state())?; + let story = serde_json::to_value(game.story())?; + + query!( + r#" update + games + set + story = $2, + state = $3, + outcome = $4, + updated_at = now() + where + id = $1"#, + game.game_id().into_uuid(), + story, + state, + game.game_over().map(Self::outcome_to_db_outcome) as _ + ) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_active_game(&self) -> Result { + let game = query!( + r#" select + id, state, story, started_at + from + games + where + outcome is null"# + ) + .fetch_one(&self.pool) + .await?; + + Ok(Game::new_from_parts( + GameId::from_uuid(game.id), + game.started_at, + serde_json::from_value(game.story)?, + serde_json::from_value(game.state)?, + )) + } + + pub async fn get_game_story(&self, id: GameId) -> Result { + let game = query!( + r#" select + story + from + games + where + id = $1 + and + outcome is not null"#, + id.into_uuid(), + ) + .fetch_one(&self.pool) + .await?; + + Ok(serde_json::from_value(game.story)?) + } + + fn outcome_to_db_outcome(outcome: GameOver) -> &'static str { + match outcome { + GameOver::VillageWins => "village_victory", + GameOver::WolvesWin => "wolves_victory", + } + } +} diff --git a/werewolves-server/src/db/mod.rs b/werewolves-server/src/db/mod.rs new file mode 100644 index 0000000..956b3c8 --- /dev/null +++ b/werewolves-server/src/db/mod.rs @@ -0,0 +1,40 @@ +pub mod game; +pub mod user; +use sqlx::{Pool, Postgres}; +use werewolves_proto::error::DatabaseError; + +use crate::db::{game::GameDatabase, user::UserDatabase}; + +type Result = core::result::Result; + +#[derive(Debug, Clone)] +pub struct Database { + pool: Pool, +} + +impl Database { + pub const fn new(pool: Pool) -> Self { + Self { pool } + } + + pub fn user(&self) -> UserDatabase { + UserDatabase { + pool: self.pool.clone(), + } + } + + pub fn game(&self) -> GameDatabase { + GameDatabase { + pool: self.pool.clone(), + } + } + + pub async fn migrate(&self) { + log::info!("running migrations"); + sqlx::migrate!("../migrations") + .run(&self.pool) + .await + .expect("run migrations"); + log::info!("migrations done"); + } +} diff --git a/werewolves-server/src/db/user.rs b/werewolves-server/src/db/user.rs new file mode 100644 index 0000000..9279ae8 --- /dev/null +++ b/werewolves-server/src/db/user.rs @@ -0,0 +1,214 @@ +use super::Result; +use argon2::{ + Argon2, PasswordHash, PasswordVerifier, + password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, +}; +use chrono::{TimeDelta, Utc}; + +use rand::distr::SampleString; +use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as}; +use werewolves_proto::{ + error::{DatabaseError, ServerError}, + token, + user::UserId, +}; + +#[derive(Debug, Clone)] +pub struct UserDatabase { + pub(super) pool: Pool, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LoginToken { + pub token: String, + pub user_id: UserId, + + pub created_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +impl LoginToken { + const TOKEN_LONGEVITY: TimeDelta = TimeDelta::days(30); + + pub fn new(user_id: UserId) -> Self { + let created_at = Utc::now(); + let expires_at = created_at + .checked_add_signed(Self::TOKEN_LONGEVITY) + .unwrap_or_else(|| { + panic!( + "could not add {} time to {created_at}", + Self::TOKEN_LONGEVITY + ) + }); + + let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), token::TOKEN_LEN); + + Self { + token, + user_id, + created_at, + expires_at, + } + } +} + +pub enum GetUserBy<'a> { + Username(&'a str), + Id(UserId), +} + +impl UserDatabase { + pub async fn create(&self, username: &str, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + + let now = chrono::offset::Utc::now(); + + let user = User { + id: UserId::new(), + username: username.into(), + password_hash, + created_at: now, + updated_at: now, + }; + + query!( + r#"insert into users + (id, username, password_hash, created_at, updated_at) + values + ($1, $2, $3, $4, $5)"#, + user.id.into_uuid(), + user.username, + user.password_hash, + user.created_at, + user.updated_at + ) + .execute(&self.pool) + .await + .map_err(|err| { + if let sqlx::Error::Database(db_err) = &err + && let Some(constraint) = db_err.constraint() + && constraint == "users_username_unique" + { + DatabaseError::UserAlreadyExists + } else { + err.into() + } + })?; + + Ok(user) + } + + pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> Result { + Ok(match get_user_by { + GetUserBy::Username(username) => { + query_as!( + User, + r#" + select + id, username, password_hash, + created_at, updated_at + from + users + where + username = $1"#, + username + ) + .fetch_one(&self.pool) + .await? + } + GetUserBy::Id(id) => { + query_as!( + User, + r#" + select + id, username, password_hash, + created_at, updated_at + from + users + where + id = $1"#, + id.into_uuid() + ) + .fetch_one(&self.pool) + .await? + } + }) + } + + pub async fn login( + &self, + username: &str, + password: &str, + ) -> core::result::Result { + let user = self.get_user(GetUserBy::Username(username)).await?; + + let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .map_err(|err| match err { + argon2::password_hash::Error::Password => ServerError::InvalidCredentials, + err => ServerError::DatabaseError(err.into()), + })?; + + let token = LoginToken::new(user.id); + + query!( + r#" insert into login_tokens + (token, user_id, created_at, expires_at) + values + ($1, $2, $3, $4)"#, + token.token, + token.user_id.into_uuid(), + token.created_at, + token.expires_at + ) + .execute(&self.pool) + .await + .map_err(Into::::into)?; + + Ok(token) + } + + pub async fn check_token(&self, token: &str) -> core::result::Result { + let token = query_as!( + LoginToken, + r#" select + token, user_id, created_at, expires_at + from + login_tokens + where + token = $1 + and + expires_at > now() + "#, + token + ) + .fetch_one(&self.pool) + .await + .map_err(Into::::into) + .map_err(|err| match err { + DatabaseError::NotFound => ServerError::ExpiredToken, + _ => err.into(), + })?; + + if Utc::now() >= token.expires_at { + return Err(ServerError::ExpiredToken); + } + + Ok(self.get_user(GetUserBy::Id(token.user_id)).await?) + } +} + +#[derive(Debug, Clone, FromRow, Encode, Decode)] +pub struct User { + pub id: UserId, + pub username: String, + pub password_hash: String, + + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} diff --git a/werewolves-server/src/main.rs b/werewolves-server/src/main.rs index a8ad5d4..52935d9 100644 --- a/werewolves-server/src/main.rs +++ b/werewolves-server/src/main.rs @@ -1,37 +1,56 @@ mod client; mod communication; mod connection; +mod db; mod game; mod host; mod lobby; mod runner; -mod saver; +// mod saver; use axum::{ - Router, + BoxError, Router, + error_handling::HandleErrorLayer, + extract::{Path, State}, http::{Request, StatusCode, header}, response::IntoResponse, - routing::{any, get}, + routing::{any, get, post, put}, +}; +use axum_extra::{ + TypedHeader, + headers::{self, Authorization}, }; -use axum_extra::headers; use communication::lobby::LobbyComms; use connection::JoinedPlayers; -use core::{fmt::Display, net::SocketAddr, str::FromStr}; +use core::{fmt::Display, net::SocketAddr, str::FromStr, time::Duration}; use fast_qr::convert::{Builder, Shape, svg::SvgBuilder}; use runner::IdentifiedClientMessage; -use std::{env, io::Write, path::Path}; +use sqlx::postgres::PgPoolOptions; +use std::{env, io::Write}; use tokio::sync::{broadcast, mpsc}; +use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer}; +use werewolves_proto::{ + cbor::Cbor, + error::ServerError, + id::GameId, + limited::FixedLenString, + token::{Token, TokenLogin}, + user::UserLogin, +}; use crate::{ communication::{Comms, connect::ConnectUpdate, host::HostComms, player::PlayerIdComms}, - saver::FileSaver, + db::Database, + // saver::FileSaver, }; const DEFAULT_PORT: u16 = 8080; const DEFAULT_HOST: &str = "127.0.0.1"; -const DEFAULT_SAVE_DIR: &str = "werewolves-saves/"; const DEFAULT_QRCODE_URL: &str = "https://wolf.emilis.dev/"; +const DEFAULT_MAX_PG_CONNECTIONS: u32 = 30; +const DEFAULT_PG_CONN_STRING: &str = "postgres:///ww?host=/var/run/postgresql"; + #[tokio::main] async fn main() { // pretty_env_logger::init(); @@ -106,39 +125,60 @@ async fn main() { let jp_clone = joined_players.clone(); - let path = Path::new(option_env!("SAVE_PATH").unwrap_or(DEFAULT_SAVE_DIR)); + let pg_pool = PgPoolOptions::new() + .max_connections( + std::env::var("MAX_DB_CONNECTIONS") + .ok() + .and_then(|val| u32::from_str(&val).ok()) + .unwrap_or(DEFAULT_MAX_PG_CONNECTIONS), + ) + .connect( + std::env::var("PG_CONN_STRING") + .unwrap_or_else(|_| String::from(DEFAULT_PG_CONN_STRING)) + .as_str(), + ) + .await + .expect("could not init db"); + let db = Database::new(pg_pool); + db.migrate().await; - if let Err(err) = std::fs::create_dir(path) - && !matches!(err.kind(), std::io::ErrorKind::AlreadyExists) - { - panic!("creating save dir at [{path:?}]: {err}") - } - - // Check if we can write to the path - { - let test_file_path = path.join(".test"); - if let Err(err) = std::fs::File::create(&test_file_path) { - panic!("can't create files in {path:?}: {err}") + // let saver = FileSaver::new(path.canonicalize().expect("canonicalizing path")); + tokio::spawn({ + let db = db.clone(); + async move { + crate::runner::run_game(jp_clone, lobby_comms, db).await; + panic!("game over"); } - std::fs::remove_file(&test_file_path).log_err(); - } - - let saver = FileSaver::new(path.canonicalize().expect("canonicalizing path")); - tokio::spawn(async move { - crate::runner::run_game(jp_clone, lobby_comms, saver).await; - panic!("game over"); }); let state = AppState { joined_players, host_recv, host_send, send, + db, }; let app = Router::new() .route("/connect/client", any(client::handler)) .route("/connect/host", any(host::handler)) .route("/qrcode", get(handle_qr_code)) + .route("/s/users", put(signup)) + .route("/s/tokens", post(signin)) + .route( + "/s/tokens/check", + get(check_token).layer( + ServiceBuilder::new() + .layer(HandleErrorLayer::new(|err: BoxError| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled error: {}", err), + ) + })) + .layer(BufferLayer::new(0x100)) + .layer(RateLimitLayer::new(100, Duration::from_secs(10))), + ), + ) + .route("/s/games/{id}", get(get_game_by_id)) .with_state(state) .fallback(get(handle_http_static)); let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap(); @@ -151,15 +191,61 @@ async fn main() { .unwrap(); } +async fn get_game_by_id( + State(AppState { db, .. }): State, + Path(game_id): Path, +) -> Result { + let story = db.game().get_game_story(game_id).await?; + Ok(Cbor(story)) +} + +async fn check_token( + State(AppState { db, .. }): State, + TypedHeader(Authorization(login)): TypedHeader>, +) -> Result { + db.user().check_token(&login.0).await?; + Ok(StatusCode::OK) +} + +async fn signin( + State(AppState { db, .. }): State, + Cbor(UserLogin { username, password }): Cbor, +) -> Result { + let token = db.user().login(&username, &password).await?; + + Ok(Cbor(Token { + username, + token: FixedLenString::new(token.token.clone()).ok_or_else(|| { + ServerError::InternalServerError(format!( + "could not get a fixed len string for token [{}]", + token.token + )) + })?, + created_at: token.created_at, + expires_at: token.expires_at, + }) + .into_response()) +} + +async fn signup( + State(AppState { db, .. }): State, + Cbor(UserLogin { username, password }): Cbor, +) -> Result { + db.user().create(&username, &password).await?; + Ok(StatusCode::CREATED) +} + struct AppState { joined_players: JoinedPlayers, send: broadcast::Sender, host_send: tokio::sync::mpsc::Sender, host_recv: broadcast::Receiver, + db: Database, } impl Clone for AppState { fn clone(&self) -> Self { Self { + db: self.db.clone(), joined_players: self.joined_players.clone(), send: self.send.clone(), host_send: self.host_send.clone(), diff --git a/werewolves-server/src/runner.rs b/werewolves-server/src/runner.rs index f9460ec..4e8be74 100644 --- a/werewolves-server/src/runner.rs +++ b/werewolves-server/src/runner.rs @@ -2,7 +2,11 @@ use core::{num::NonZeroU8, time::Duration}; use std::sync::Arc; use werewolves_proto::{ - message::{ClientMessage, Identification, host::HostMessage}, + error::{GameError, ServerError}, + message::{ + ClientMessage, Identification, + host::{HostMessage, ServerToHostMessage}, + }, player::PlayerId, }; @@ -10,9 +14,9 @@ use crate::{ LogError, communication::lobby::LobbyComms, connection::JoinedPlayers, + db::Database, game::{GameEnd, GameRunner}, lobby::{Lobby, LobbyPlayers}, - saver::Saver, }; #[derive(Debug, Clone, PartialEq)] @@ -21,7 +25,7 @@ pub struct IdentifiedClientMessage { pub message: ClientMessage, } -pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut saver: impl Saver) { +pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, db: Database) { let mut lobby = Lobby::new(joined_players, comms); if let Some(dummies) = option_env!("DUMMY_PLAYERS").and_then(|p| p.parse::().ok()) { log::info!("creating {dummies} dummy players"); @@ -32,36 +36,37 @@ pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut save loop { match &mut state { RunningState::Lobby(lobby) => { - if let Some(game) = lobby.next().await { + if let Some(mut game) = lobby.next().await { + if let Err(err) = db.game().new_game(game.proto_game()).await { + log::error!("saving new game: {err}; reverting to lobby"); + game.comms() + .host() + .send(ServerToHostMessage::Error(GameError::ServerError( + ServerError::DatabaseError(err), + ))) + .log_err(); + + state = RunningState::Lobby(game.into_lobby()); + continue; + } state = RunningState::Game(game) } } RunningState::Game(game) => { if let Some(result) = game.next().await { - match saver.save(game.proto_game()) { - Ok(path) => { - log::info!("saved game to {path}"); - } - Err(err) => { - log::error!("saving game: {err}"); - let game_clone = game.proto_game().clone(); - let mut saver_clone = saver.clone(); - tokio::spawn(async move { - let started = chrono::Utc::now(); - loop { - tokio::time::sleep(Duration::from_secs(30)).await; - match saver_clone.save(&game_clone) { - Ok(path) => { - log::info!("saved game from {started} to {path}"); - return; - } - Err(err) => { - log::error!("saving game from {started}: {err}") - } - } + if let Err(err) = db.game().update_game(game.proto_game()).await { + log::error!("saving game ({}): {err}", game.proto_game().game_id()); + let game_clone = game.proto_game().clone(); + let db_clone = db.game(); + tokio::spawn(async move { + let started = chrono::Utc::now(); + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + if let Err(err) = db_clone.update_game(&game_clone).await { + log::error!("saving game from {started}: {err}") } - }); - } + } + }); } state = match state { RunningState::Game(game) => { @@ -69,6 +74,10 @@ pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut save } _ => unsafe { core::hint::unreachable_unchecked() }, }; + } else { + if let Err(err) = db.game().update_game(game.proto_game()).await { + log::error!("updating game ({}): {err}", game.proto_game().game_id()); + } } } RunningState::GameOver(end) => { diff --git a/werewolves/Trunk.toml b/werewolves/Trunk.toml index 105927b..dc4e510 100644 --- a/werewolves/Trunk.toml +++ b/werewolves/Trunk.toml @@ -1,7 +1,8 @@ [build] target = "index.html" # The index HTML file to drive the bundling process. html_output = "index.html" # The name of the output HTML file. -release = true # Build in release mode. +release = false # Build in release mode. +# release = true # Build in release mode. dist = "dist" # The output dir for all final assets. public_url = "/" # The public URL from which assets are to be served. filehash = true # Whether to include hash values in the output file names. @@ -9,6 +10,6 @@ inject_scripts = true # Whether to inject scripts (and module preloads) in offline = false # Run without network access frozen = false # Require Cargo.lock and cache are up to date locked = false # Require Cargo.lock is up to date -# minify = "on_release" # Control minification: can be one of: never, on_release, always -minify = "always" # Control minification: can be one of: never, on_release, always +minify = "on_release" # Control minification: can be one of: never, on_release, always +# minify = "always" # Control minification: can be one of: never, on_release, always no_sri = false # Allow disabling sub-resource integrity (SRI)