diff --git a/Cargo.lock b/Cargo.lock index 4ac8da4..375dd58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,16 @@ version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde 1.0.210", + "serde_json", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -284,12 +294,115 @@ dependencies = [ "uuid 0.4.0", ] +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde 1.0.210", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "axum-test" +version = "16.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http 1.2.0", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde 1.0.210", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "az" version = "1.2.1" @@ -428,9 +541,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "bytesize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e368af43e418a04d52505cf3dbc23dda4e3407ae2fa99fd0e4f308ce546acc" [[package]] name = "cairo-rs" @@ -642,6 +761,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "cookie-factory" version = "0.3.3" @@ -846,6 +975,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -932,6 +1076,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1850,9 +2007,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -1870,6 +2027,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.2.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.9.5" @@ -1882,6 +2062,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.30" @@ -1894,7 +2080,7 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1906,6 +2092,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1913,12 +2119,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.30", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.5.2", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -2049,6 +2274,17 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2254,6 +2490,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -2440,6 +2682,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -2815,6 +3063,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2824,6 +3078,26 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3078,8 +3352,8 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", - "hyper", + "http-body 0.4.6", + "hyper 0.14.30", "hyper-tls", "ipnet", "js-sys", @@ -3093,7 +3367,7 @@ dependencies = [ "serde 1.0.210", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -3105,6 +3379,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "reserve-port" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" +dependencies = [ + "lazy_static", + "thiserror 1.0.64", +] + [[package]] name = "result-extended" version = "0.1.0" @@ -3157,6 +3441,22 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.5", + "thiserror 1.0.64", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3200,6 +3500,12 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + [[package]] name = "rusty-fork" version = "0.3.0" @@ -3329,6 +3635,16 @@ dependencies = [ "serde 1.0.210", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde 1.0.210", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -3760,6 +4076,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "system-configuration" version = "0.5.1" @@ -3813,6 +4135,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.64" @@ -3864,6 +4195,37 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde 1.0.210", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timezone-testing" version = "0.1.0" @@ -3898,9 +4260,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -4016,6 +4378,42 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "http 1.2.0", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -4073,7 +4471,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.1.0", + "http 1.2.0", "httparse", "log", "rand 0.8.5", @@ -4287,13 +4685,15 @@ dependencies = [ "async-std", "async-trait", "authdb", + "axum", + "axum-test", "cool_asserts", "futures", - "http 1.1.0", "include_dir", "lazy_static", "mime", "mime_guess", + "pretty_env_logger", "result-extended", "rusqlite", "rusqlite_migration", @@ -4302,10 +4702,10 @@ dependencies = [ "thiserror 2.0.3", "tokio", "tokio-stream", + "tower-http", "typeshare", "urlencoding", "uuid 1.11.0", - "warp", ] [[package]] @@ -4337,7 +4737,7 @@ dependencies = [ "futures-util", "headers", "http 0.2.12", - "hyper", + "hyper 0.14.30", "log", "mime", "mime_guess", @@ -4476,6 +4876,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4658,6 +5067,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yansi-term" version = "0.1.2" diff --git a/flake.nix b/flake.nix index 74e8908..3d8c97e 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ name = "ld-tools-devshell"; buildInputs = [ pkgs.cargo-nextest + pkgs.cargo-watch pkgs.clang pkgs.crate2nix pkgs.glib diff --git a/rust-toolchain b/rust-toolchain index 635cbe4..b96bf13 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1,3 +1,4 @@ [toolchain] channel = "1.81.0" targets = [ "wasm32-unknown-unknown", "thumbv6m-none-eabi" ] +components = [ "rustfmt", "rust-analyzer", "clippy" ] diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 0d02396..619ae02 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -9,12 +9,13 @@ edition = "2021" async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } +axum = { version = "0.7.9", features = [ "macros" ] } futures = { version = "0.3.31" } -http = { version = "1" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } mime = { version = "0.3.17" } mime_guess = { version = "2.0.5" } +pretty_env_logger = { version = "0.5.0" } result-extended = { path = "../../result-extended" } rusqlite = { version = "0.32.1" } rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } @@ -23,10 +24,11 @@ serde_json = { version = "*" } thiserror = { version = "2.0.3" } tokio = { version = "1", features = [ "full" ] } tokio-stream = { version = "0.1.16" } +tower-http = { version = "0.6.2", features = ["cors"] } typeshare = { version = "1.0.4" } urlencoding = { version = "2.1.3" } uuid = { version = "1.11.0", features = ["v4"] } -warp = { version = "0.3" } [dev-dependencies] cool_asserts = "2.0.3" +axum-test = "16.4.1" diff --git a/visions/server/Taskfile.yml b/visions/server/Taskfile.yml index d54c919..df7c99e 100644 --- a/visions/server/Taskfile.yml +++ b/visions/server/Taskfile.yml @@ -7,9 +7,17 @@ tasks: test: cmds: - # - cargo watch -x 'test -- --nocapture' - cargo watch -x 'nextest run' dev: cmds: - cargo watch -x run + + lint: + cmds: + - cargo watch -x clippy + + release: + cmds: + - task lint + - cargo build --release diff --git a/visions/server/migrations/01-initial-db/up.sql b/visions/server/migrations/01-initial-db/up.sql index 3e822af..8d0a733 100644 --- a/visions/server/migrations/01-initial-db/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -1,14 +1,25 @@ CREATE TABLE users( uuid TEXT PRIMARY KEY, - name TEXT, + name TEXT UNIQUE, password TEXT, admin BOOLEAN, enabled BOOLEAN ); +CREATE TABLE sessions( + id TEXT PRIMARY KEY, + user_id TEXT, + + FOREIGN KEY(user_id) REFERENCES users(uuid) +); + CREATE TABLE games( uuid TEXT PRIMARY KEY, - name TEXT + gm TEXT, + game_type TEXT, + name TEXT, + + FOREIGN KEY(gm) REFERENCES users(uuid) ); CREATE TABLE characters( @@ -28,5 +39,4 @@ CREATE TABLE roles( FOREIGN KEY(game_id) REFERENCES games(uuid) ); -INSERT INTO users VALUES ("admin", "admin", "", true, true); - +INSERT INTO users VALUES ('admin', 'admin', '', true, true); diff --git a/visions/server/src/asset_db.rs b/visions/server/src/asset_db.rs index 7b7a786..c84e5c9 100644 --- a/visions/server/src/asset_db.rs +++ b/visions/server/src/asset_db.rs @@ -15,7 +15,7 @@ pub enum Error { Inaccessible, #[error("An unexpected IO error occured when retrieving an asset {0}")] - UnexpectedError(std::io::Error), + Unexpected(std::io::Error), } impl From for Error { @@ -25,7 +25,7 @@ impl From for Error { match err.kind() { NotFound => Error::NotFound, PermissionDenied | UnexpectedEof => Error::Inaccessible, - _ => Error::UnexpectedError(err), + _ => Error::Unexpected(err), } } } @@ -35,7 +35,7 @@ impl From for Error { pub struct AssetId(String); impl AssetId { - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } @@ -69,7 +69,7 @@ impl<'a> Iterator for AssetIter<'a> { } pub trait Assets { - fn assets<'a>(&'a self) -> AssetIter<'a>; + fn assets(&self) -> AssetIter; fn get(&self, asset_id: AssetId) -> Result<(Mime, Vec), Error>; } @@ -95,7 +95,7 @@ impl FsAssets { } impl Assets for FsAssets { - fn assets<'a>(&'a self) -> AssetIter<'a> { + fn assets(&self) -> AssetIter { AssetIter(self.assets.iter()) } @@ -104,9 +104,9 @@ impl Assets for FsAssets { Some(asset) => Ok(asset), None => Err(Error::NotFound), }?; - let mime = mime_guess::from_path(&path).first().unwrap(); + let mime = mime_guess::from_path(path).first().unwrap(); let mut content: Vec = Vec::new(); - let mut file = std::fs::File::open(&path)?; + let mut file = std::fs::File::open(path)?; file.read_to_end(&mut content)?; Ok((mime, content)) } diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 79fbda9..e9224f6 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -10,11 +10,10 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, UserId}, - types::{AppError, FatalError, Game, Message, Tabletop, User, RGB}, + database::{CharacterId, Database, GameId, SessionId, UserId}, types::{AppError, FatalError, Game, GameOverview, Message, Rgb, Tabletop, User, UserProfile}, }; -const DEFAULT_BACKGROUND_COLOR: RGB = RGB { +const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { red: 0xca, green: 0xb9, blue: 0xbb, @@ -60,8 +59,8 @@ impl Core { } pub async fn status(&self) -> ResultExt { - let mut state = self.0.write().await; - let admin_user = return_error!(match state.db.user(UserId::from("admin")).await { + let state = self.0.write().await; + let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await { Ok(Some(admin_user)) => ok(admin_user), Ok(None) => { return ok(Status { @@ -106,19 +105,66 @@ impl Core { } } - pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { - let users = self.0.write().await.db.users().await; - match users { - Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()), + pub async fn user_by_username( + &self, + username: &str, + ) -> ResultExt, AppError, FatalError> { + let state = self.0.read().await; + match state.db.user_by_username(username).await { + Ok(Some(user_row)) => ok(Some(User::from(user_row))), + Ok(None) => ok(None), Err(err) => fatal(err), } } - pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { - let games = self.0.write().await.db.games().await; + pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { + let users = self.0.write().await.db.users().await; + match users { + Ok(users) => ok(users.into_iter().map(User::from).collect()), + Err(err) => fatal(err), + } + } + + pub async fn user(&self, user_id: UserId) -> ResultExt, AppError, FatalError> { + let users = return_error!(self.list_users().await); + let games = return_error!(self.list_games().await); + let user = match users.into_iter().find(|user| user.id == user_id) { + Some(user) => user, + None => return ok(None), + }; + let user_games = games.into_iter().filter(|g| g.gm == user.id).collect(); + ok(Some(UserProfile { + id: user.id, + name: user.name, + games: user_games, + is_admin: user.admin, + })) + } + + pub async fn create_user(&self, username: &str) -> ResultExt { + let state = self.0.read().await; + match return_error!(self.user_by_username(username).await) { + Some(_) => error(AppError::UsernameUnavailable), + None => match state.db.save_user(None, username, "", false, true).await { + Ok(user_id) => ok(user_id), + Err(err) => fatal(err), + }, + } + } + + pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { + let games = self.0.read().await.db.games().await; match games { // Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()), - Ok(games) => unimplemented!(), + Ok(games) => ok(games.into_iter().map(GameOverview::from).collect()), + Err(err) => fatal(err), + } + } + + pub async fn create_game(&self, gm: &UserId, game_type: &str, game_name: &str) -> ResultExt { + let state = self.0.read().await; + match state.db.save_game(None, gm, game_type, game_name).await { + Ok(game_id) => ok(game_id), Err(err) => fatal(err), } } @@ -142,9 +188,7 @@ impl Core { asset_db::Error::Inaccessible => { AppError::Inaccessible(format!("{}", asset_id)) } - asset_db::Error::UnexpectedError(err) => { - AppError::Inaccessible(format!("{}", err)) - } + asset_db::Error::Unexpected(err) => AppError::Inaccessible(format!("{}", err)), }), ) } @@ -156,7 +200,7 @@ impl Core { .asset_store .assets() .filter_map( - |(asset_id, value)| match mime_guess::from_path(&value).first() { + |(asset_id, value)| match mime_guess::from_path(value).first() { Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()), _ => None, }, @@ -182,7 +226,7 @@ impl Core { id: CharacterId, ) -> ResultExt, AppError, FatalError> { let mut state = self.0.write().await; - let cr = state.db.character(id).await; + let cr = state.db.character(&id).await; match cr { Ok(Some(row)) => ok(Some(row.data)), Ok(None) => ok(None), @@ -200,13 +244,32 @@ impl Core { }); } + pub async fn save_user( + &self, + uuid: Option, + username: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> ResultExt { + let state = self.0.read().await; + match state + .db + .save_user(uuid, username, password, admin, enabled) + .await + { + Ok(uuid) => ok(uuid), + Err(err) => fatal(err), + } + } + pub async fn set_password( &self, uuid: UserId, password: String, ) -> ResultExt<(), AppError, FatalError> { - let mut state = self.0.write().await; - let user = match state.db.user(uuid.clone()).await { + let state = self.0.write().await; + let user = match state.db.user(&uuid).await { Ok(Some(row)) => row, Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())), Err(err) => return fatal(err), @@ -220,6 +283,31 @@ impl Core { Err(err) => fatal(err), } } + + pub async fn auth( + &self, + username: &str, + password: &str, + ) -> ResultExt { + let state = self.0.write().await; + match state.db.user_by_username(username).await { + Ok(Some(row)) if (row.password == password) => { + let session_id = state.db.create_session(&row.id).await.unwrap(); + ok(session_id) + } + Ok(_) => error(AppError::AuthFailed), + Err(err) => fatal(err), + } + } + + pub async fn session(&self, session_id: &SessionId) -> ResultExt, AppError, FatalError> { + let state = self.0.read().await; + match state.db.session(session_id).await { + Ok(Some(user_row)) => ok(Some(User::from(user_row))), + Ok(None) => ok(None), + Err(fatal_error) => fatal(fatal_error), + } + } } #[cfg(test)] @@ -230,12 +318,9 @@ mod test { use cool_asserts::assert_matches; - use crate::{ - asset_db::mocks::MemoryAssets, - database::{DbConn, DiskDb}, - }; + use crate::{asset_db::mocks::MemoryAssets, database::DbConn}; - fn test_core() -> Core { + async fn test_core() -> Core { let assets = MemoryAssets::new(vec![ ( AssetId::from("asset_1"), @@ -265,19 +350,25 @@ mod test { ]); let memory_db: Option = None; let conn = DbConn::new(memory_db); + conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) + .await + .unwrap(); + conn.save_user(None, "gm_1", "aoeu", false, true) + .await + .unwrap(); Core::new(assets, conn) } #[tokio::test] async fn it_lists_available_images() { - let core = test_core(); + let core = test_core().await; let image_paths = core.available_images().await; assert_eq!(image_paths.len(), 2); } #[tokio::test] async fn it_retrieves_an_asset() { - let core = test_core(); + let core = test_core().await; assert_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => { assert_eq!(mime.type_(), mime::IMAGE); assert_eq!(data, "abcdefg".as_bytes()); @@ -286,7 +377,7 @@ mod test { #[tokio::test] async fn it_can_retrieve_the_default_tabletop() { - let core = test_core(); + let core = test_core().await; assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => { assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR); assert_eq!(background_image, None); @@ -295,7 +386,7 @@ mod test { #[tokio::test] async fn it_can_change_the_tabletop_background() { - let core = test_core(); + let core = test_core().await; assert_matches!( core.set_background_image(AssetId::from("asset_1")).await, ResultExt::Ok(()) @@ -308,7 +399,7 @@ mod test { #[tokio::test] async fn it_sends_notices_to_clients_on_tabletop_change() { - let core = test_core(); + let core = test_core().await; let client_id = core.register_client().await; let mut receiver = core.connect_client(client_id).await; @@ -327,4 +418,21 @@ mod test { None => panic!("receiver did not get a message"), } } + + #[tokio::test] + async fn it_creates_a_sessionid_on_successful_auth() { + let core = test_core().await; + match core.auth("admin", "aoeu").await { + ResultExt::Ok(session_id) => { + let st = core.0.read().await; + match st.db.session(&session_id).await { + Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"), + Ok(None) => panic!("no matching user row for the session id"), + Err(err) => panic!("{}", err), + } + } + ResultExt::Err(err) => panic!("{}", err), + ResultExt::Fatal(err) => panic!("{}", err), + } + } } diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs deleted file mode 100644 index 25b27b7..0000000 --- a/visions/server/src/database.rs +++ /dev/null @@ -1,651 +0,0 @@ -use std::path::Path; - -use async_std::channel::{bounded, Receiver, Sender}; -use async_trait::async_trait; -use include_dir::{include_dir, Dir}; -use lazy_static::lazy_static; -use rusqlite::{ - types::{FromSql, FromSqlResult, ValueRef}, - Connection, -}; -use rusqlite_migration::Migrations; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::types::FatalError; - -static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); - -lazy_static! { - static ref MIGRATIONS: Migrations<'static> = - Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); -} - -#[derive(Debug)] -enum Request { - Charsheet(CharacterId), - Games, - User(UserId), - Users, - SaveUser(Option, String, String, bool, bool), -} - -#[derive(Debug)] -struct DatabaseRequest { - tx: Sender, - req: Request, -} - -#[derive(Debug)] -enum DatabaseResponse { - Charsheet(Option), - Games(Vec), - User(Option), - Users(Vec), - SaveUser(UserId), -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct UserId(String); - -impl UserId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for UserId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for UserId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for UserId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct GameId(String); - -impl GameId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for GameId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for GameId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for GameId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct CharacterId(String); - -impl CharacterId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for CharacterId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for CharacterId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for CharacterId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug)] -pub struct UserRow { - pub id: UserId, - pub name: String, - pub password: String, - pub admin: bool, - pub enabled: bool, -} - -#[derive(Clone, Debug)] -pub struct Role { - userid: UserId, - gameid: GameId, - role: String, -} - -#[derive(Clone, Debug)] -pub struct GameRow { - pub id: UserId, - pub name: String, -} - -#[derive(Clone, Debug)] -pub struct CharsheetRow { - id: String, - game: GameId, - pub data: serde_json::Value, -} - -#[async_trait] -pub trait Database: Send + Sync { - async fn user(&mut self, _: UserId) -> Result, FatalError>; - - async fn save_user( - &mut self, - user_id: Option, - name: &str, - password: &str, - admin: bool, - enabled: bool, - ) -> Result; - - async fn users(&mut self) -> Result, FatalError>; - - async fn games(&mut self) -> Result, FatalError>; - - async fn character(&mut self, id: CharacterId) -> Result, FatalError>; -} - -pub struct DiskDb { - conn: Connection, -} - -/* -fn setup_test_database(conn: &Connection) -> Result<(), FatalError> { - let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); - let mut count = gamecount_stmt.query([]).unwrap(); - if count.next().unwrap().unwrap().get::(0) == Ok(0) { - let admin_id = format!("{}", Uuid::new_v4()); - let user_id = format!("{}", Uuid::new_v4()); - let game_id = format!("{}", Uuid::new_v4()); - let char_id = CharacterId::new(); - - let mut user_stmt = conn - .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - user_stmt - .execute((admin_id.clone(), "admin", "abcdefg", true, true)) - .unwrap(); - user_stmt - .execute((user_id.clone(), "savanni", "abcdefg", false, true)) - .unwrap(); - - let mut game_stmt = conn - .prepare("INSERT INTO games VALUES (?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - game_stmt - .execute((game_id.clone(), "Circle of Bluest Sky")) - .unwrap(); - - let mut role_stmt = conn - .prepare("INSERT INTO roles VALUES (?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - role_stmt - .execute((user_id.clone(), game_id.clone(), "gm")) - .unwrap(); - - let mut sheet_stmt = conn - .prepare("INSERT INTO characters VALUES (?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - - sheet_stmt.execute((char_id.as_str(), game_id, r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#)) - .unwrap(); - } - - Ok(()) -} -*/ - -impl DiskDb { - pub fn new

(path: Option

) -> Result - where - P: AsRef, - { - let mut conn = match path { - None => Connection::open(":memory:").expect("to create a memory connection"), - Some(path) => Connection::open(path).expect("to create connection"), - }; - - MIGRATIONS - .to_latest(&mut conn) - .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; - - // setup_test_database(&conn)?; - - Ok(DiskDb { conn }) - } - - fn user(&self, id: UserId) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items: Vec = stmt - .query_map([id.as_str()], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), - } - } - - fn users(&self) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT * FROM users") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items = stmt - .query_map([], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - Ok(items) - } - - fn save_user( - &self, - user_id: Option, - name: &str, - password: &str, - admin: bool, - enabled: bool, - ) -> Result { - match user_id { - None => { - let user_id = UserId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((user_id.as_str(), name, password, admin, enabled)) - .unwrap(); - Ok(user_id) - } - Some(user_id) => { - let mut stmt = self - .conn - .prepare( - "UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?", - ) - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, password, admin, enabled, user_id.as_str())) - .unwrap(); - Ok(user_id) - } - } - } - - fn save_game(&self, game_id: Option, name: &str) -> Result { - match game_id { - None => { - let game_id = GameId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO games VALUES (?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((game_id.as_str(), name)).unwrap(); - Ok(game_id) - } - Some(game_id) => { - let mut stmt = self - .conn - .prepare("UPDATE games SET name=? WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, game_id.as_str())).unwrap(); - Ok(game_id) - } - } - } - - fn character(&self, id: CharacterId) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT uuid, game, data FROM characters WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items: Vec = stmt - .query_map([id.as_str()], |row| { - let data: String = row.get(2).unwrap(); - Ok(CharsheetRow { - id: row.get(0).unwrap(), - game: row.get(1).unwrap(), - data: serde_json::from_str(&data).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), - } - } - - fn save_character( - &self, - char_id: Option, - game: GameId, - character: serde_json::Value, - ) -> std::result::Result { - match char_id { - None => { - let char_id = CharacterId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO characters VALUES (?, ?, ?)") - .unwrap(); - stmt.execute((char_id.as_str(), game.as_str(), character.to_string())) - .unwrap(); - - Ok(char_id) - } - Some(char_id) => { - let mut stmt = self - .conn - .prepare("UPDATE characters SET data=? WHERE uuid=?") - .unwrap(); - stmt.execute((character.to_string(), char_id.as_str())) - .unwrap(); - - Ok(char_id) - } - } - } -} - -async fn db_handler(db: DiskDb, requestor: Receiver) { - while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await { - println!("Request received: {:?}", req); - match req { - Request::Charsheet(id) => { - let sheet = db.character(id); - println!("sheet retrieved: {:?}", sheet); - match sheet { - Ok(sheet) => { - tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); - } - _ => unimplemented!(), - } - } - Request::Games => { - unimplemented!(); - } - Request::User(uid) => { - let user = db.user(uid); - match user { - Ok(user) => { - tx.send(DatabaseResponse::User(user)).await.unwrap(); - } - err => panic!("{:?}", err), - } - } - Request::SaveUser(user_id, username, password, admin, enabled) => { - let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled); - match user_id { - Ok(user_id) => { - tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); - } - err => panic!("{:?}", err), - } - } - Request::Users => { - let users = db.users(); - match users { - Ok(users) => { - tx.send(DatabaseResponse::Users(users)).await.unwrap(); - } - _ => unimplemented!(), - } - } - } - } - println!("ending db_handler"); -} - -pub struct DbConn { - conn: Sender, - handle: tokio::task::JoinHandle<()>, -} - -impl DbConn { - pub fn new

(path: Option

) -> Self - where - P: AsRef, - { - let (tx, rx) = bounded::(5); - let db = DiskDb::new(path).unwrap(); - - let handle = tokio::spawn(async move { - db_handler(db, rx).await; - }); - - Self { conn: tx, handle } - } -} - -#[async_trait] -impl Database for DbConn { - async fn user(&mut self, uid: UserId) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::User(uid), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::User(user)) => Ok(user), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } - } - - async fn save_user( - &mut self, - user_id: Option, - name: &str, - password: &str, - admin: bool, - enabled: bool, - ) -> Result { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::SaveUser( - user_id, - name.to_owned(), - password.to_owned(), - admin, - enabled, - ), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::SaveUser(user_id)) => Ok(user_id), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } - } - - async fn users(&mut self) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Users, - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Users(lst)) => Ok(lst), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } - } - - async fn games(&mut self) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Games, - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Games(lst)) => Ok(lst), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } - } - - async fn character(&mut self, id: CharacterId) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Charsheet(id), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Charsheet(row)) => Ok(row), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } - } -} - -#[cfg(test)] -mod test { - use std::path::PathBuf; - - use cool_asserts::assert_matches; - - use super::*; - - const soren: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; - - fn setup_db() -> (DiskDb, GameId) { - let no_path: Option = None; - let db = DiskDb::new(no_path).unwrap(); - - db.save_user(None, "admin", "abcdefg", true, true); - let game_id = db.save_game(None, "Candela").unwrap(); - (db, game_id) - } - - #[test] - fn it_can_retrieve_a_character() { - let (db, game_id) = setup_db(); - - assert_matches!(db.character(CharacterId::from("1")), Ok(None)); - - let js: serde_json::Value = serde_json::from_str(soren).unwrap(); - let soren_id = db.save_character(None, game_id, js.clone()).unwrap(); - assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); - } - - #[tokio::test] - async fn it_can_retrieve_a_character_through_conn() { - let memory_db: Option = None; - let mut conn = DbConn::new(memory_db); - - assert_matches!( - conn.character(CharacterId::from("1")).await, - ResultExt::Ok(None) - ); - } -} diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs new file mode 100644 index 0000000..5cc98eb --- /dev/null +++ b/visions/server/src/database/disk_db.rs @@ -0,0 +1,386 @@ +use std::path::Path; + +use async_std::channel::Receiver; +use include_dir::{include_dir, Dir}; +use lazy_static::lazy_static; +use rusqlite::Connection; +use rusqlite_migration::Migrations; + +use crate::{ + database::{DatabaseResponse, Request}, + types::FatalError, +}; + +use super::{ + types::GameId, CharacterId, CharsheetRow, DatabaseRequest, GameRow, SessionId, UserId, UserRow +}; + +static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); + +lazy_static! { + static ref MIGRATIONS: Migrations<'static> = + Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); +} + +pub struct DiskDb { + conn: Connection, +} + +impl DiskDb { + pub fn new

(path: Option

) -> Result + where + P: AsRef, + { + let mut conn = match path { + None => Connection::open(":memory:").expect("to create a memory connection"), + Some(path) => Connection::open(path).expect("to create connection"), + }; + + MIGRATIONS + .to_latest(&mut conn) + .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; + + // setup_test_database(&conn)?; + + Ok(DiskDb { conn }) + } + + pub fn user(&self, id: &UserId) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([id.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), + } + } + + pub fn user_by_username(&self, username: &str) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([username], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(username.to_owned())), + } + } + + pub fn save_user( + &self, + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result { + match user_id { + None => { + let user_id = UserId::default(); + let mut stmt = self + .conn + .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((user_id.as_str(), name, password, admin, enabled)) + .unwrap(); + Ok(user_id) + } + Some(user_id) => { + let mut stmt = self + .conn + .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((name, password, admin, enabled, user_id.as_str())) + .unwrap(); + Ok(user_id) + } + } + } + + pub fn users(&self) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT * FROM users") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items = stmt + .query_map([], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + Ok(items) + } + + pub fn save_game( + &self, + game_id: Option, + gm: &UserId, + game_type: &str, + name: &str, + ) -> Result { + match game_id { + None => { + let game_id = GameId::new(); + let mut stmt = self + .conn + .prepare("INSERT INTO games VALUES (?, ?, ?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((game_id.as_str(), gm.as_str(), game_type, name)) + .unwrap(); + Ok(game_id) + } + Some(game_id) => { + let mut stmt = self + .conn + .prepare("UPDATE games SET gm=? game_type=? name=? WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((gm.as_str(), game_type, name, game_id.as_str())) + .unwrap(); + Ok(game_id) + } + } + } + + pub fn games(&self) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT * FROM games") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items = stmt + .query_map([], |row| { + Ok(GameRow { + id: row.get(0).unwrap(), + gm: row.get(1).unwrap(), + game_type: row.get(2).unwrap(), + name: row.get(3).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + Ok(items) + } + + pub fn session(&self, session_id: &SessionId) -> Result, FatalError> { + let mut stmt = self.conn + .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let items: Vec = stmt + .query_map([session_id.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey( + session_id.as_str().to_owned(), + )), + } + } + + fn create_session(&self, user_id: &UserId) -> Result { + match self.user(user_id) { + Ok(Some(_)) => { + let mut stmt = self + .conn + .prepare("INSERT INTO sessions VALUES (?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let session_id = SessionId::new(); + stmt.execute((session_id.as_str(), user_id.as_str())) + .unwrap(); + Ok(session_id) + } + Ok(None) => Err(FatalError::DatabaseKeyMissing), + Err(err) => Err(err), + } + } + + pub fn character(&self, id: CharacterId) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, game, data FROM characters WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([id.as_str()], |row| { + let data: String = row.get(2).unwrap(); + Ok(CharsheetRow { + id: row.get(0).unwrap(), + game: row.get(1).unwrap(), + data: serde_json::from_str(&data).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), + } + } + + pub fn save_character( + &self, + char_id: Option, + game: GameId, + character: serde_json::Value, + ) -> std::result::Result { + match char_id { + None => { + let char_id = CharacterId::new(); + let mut stmt = self + .conn + .prepare("INSERT INTO characters VALUES (?, ?, ?)") + .unwrap(); + stmt.execute((char_id.as_str(), game.as_str(), character.to_string())) + .unwrap(); + + Ok(char_id) + } + Some(char_id) => { + let mut stmt = self + .conn + .prepare("UPDATE characters SET data=? WHERE uuid=?") + .unwrap(); + stmt.execute((character.to_string(), char_id.as_str())) + .unwrap(); + + Ok(char_id) + } + } + } +} + +pub async fn db_handler(db: DiskDb, requestor: Receiver) { + while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await { + match req { + Request::Charsheet(id) => { + let sheet = db.character(id); + match sheet { + Ok(sheet) => { + tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); + } + _ => unimplemented!("errors for Charsheet"), + } + } + Request::CreateSession(id) => { + let session_id = db.create_session(&id).unwrap(); + tx.send(DatabaseResponse::CreateSession(session_id)) + .await + .unwrap(); + } + Request::Games => { + match db.games() { + Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(), + _ => unimplemented!("errors for Request::Games"), + } + } + Request::Game(_game_id) => { + unimplemented!("Request::Game handler"); + } + Request::SaveGame(game_id, user_id, game_type, game_name) => { + let game_id = db.save_game(game_id, &user_id, &game_type, &game_name); + match game_id { + Ok(game_id) => { + tx.send(DatabaseResponse::SaveGame(game_id)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } + Request::User(uid) => { + let user = db.user(&uid); + match user { + Ok(user) => { + tx.send(DatabaseResponse::User(user)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } + Request::UserByUsername(username) => { + let user = db.user_by_username(&username); + match user { + Ok(user) => tx.send(DatabaseResponse::User(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } + Request::SaveUser(user_id, username, password, admin, enabled) => { + let user_id = db.save_user( + user_id, + username.as_ref(), + password.as_ref(), + admin, + enabled, + ); + match user_id { + Ok(user_id) => { + tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } + Request::Session(session_id) => { + let user = db.session(&session_id); + match user { + Ok(user) => tx.send(DatabaseResponse::Session(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } + Request::Users => { + let users = db.users(); + match users { + Ok(users) => { + tx.send(DatabaseResponse::Users(users)).await.unwrap(); + } + _ => unimplemented!(), + } + } + } + } +} diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs new file mode 100644 index 0000000..02b040b --- /dev/null +++ b/visions/server/src/database/mod.rs @@ -0,0 +1,228 @@ +mod disk_db; +mod types; + +use std::path::Path; + +use async_std::channel::{bounded, Sender}; +use async_trait::async_trait; +use disk_db::{db_handler, DiskDb}; +pub use types::{CharacterId, CharsheetRow, GameId, GameRow, SessionId, UserId, UserRow}; + +use crate::types::FatalError; + +#[derive(Debug)] +enum Request { + Charsheet(CharacterId), + CreateSession(UserId), + Games, + Game(GameId), + SaveGame(Option, UserId, String, String), + SaveUser(Option, String, String, bool, bool), + Session(SessionId), + User(UserId), + UserByUsername(String), + Users, +} + +#[derive(Debug)] +struct DatabaseRequest { + tx: Sender, + req: Request, +} + +#[derive(Debug)] +enum DatabaseResponse { + Charsheet(Option), + CreateSession(SessionId), + Games(Vec), + Game(Option), + SaveGame(GameId), + SaveUser(UserId), + Session(Option), + User(Option), + Users(Vec), +} + +#[async_trait] +pub trait Database: Send + Sync { + async fn users(&self) -> Result, FatalError>; + + async fn user(&self, _: &UserId) -> Result, FatalError>; + + async fn user_by_username(&self, _: &str) -> Result, FatalError>; + + async fn save_user( + &self, + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result; + + async fn games(&self) -> Result, FatalError>; + + async fn game(&self, _: &GameId) -> Result, FatalError>; + + async fn save_game( + &self, + game_id: Option, + gm: &UserId, + game_type: &str, + game_name: &str, + ) -> Result; + + async fn character(&self, id: &CharacterId) -> Result, FatalError>; + + async fn session(&self, id: &SessionId) -> Result, FatalError>; + + async fn create_session(&self, id: &UserId) -> Result; +} + +pub struct DbConn { + conn: Sender, + handle: tokio::task::JoinHandle<()>, +} + +impl DbConn { + pub fn new

(path: Option

) -> Self + where + P: AsRef, + { + let (tx, rx) = bounded::(5); + let db = DiskDb::new(path).unwrap(); + + let handle = tokio::spawn(async move { + db_handler(db, rx).await; + }); + + Self { conn: tx, handle } + } +} + +macro_rules! send_request { + ($s:expr, $req:expr, $resp_h:pat => $block:expr) => {{ + let (tx, rx) = bounded::(1); + let request = DatabaseRequest { tx, req: $req }; + match $s.conn.send(request).await { + Ok(()) => (), + Err(_) => return Err(FatalError::DatabaseConnectionLost), + }; + + match rx + .recv() + .await + .map_err(|_| FatalError::DatabaseConnectionLost) + { + Ok($resp_h) => $block, + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + }}; +} + +#[async_trait] +impl Database for DbConn { + async fn users(&self) -> Result, FatalError> { + send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) + } + + async fn user(&self, uid: &UserId) -> Result, FatalError> { + send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user)) + } + + async fn user_by_username(&self, username: &str) -> Result, FatalError> { + send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user)) + } + + async fn save_user( + &self, + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result { + send_request!(self, + Request::SaveUser( + user_id, + name.to_owned(), + password.to_owned(), + admin, + enabled, + ), + DatabaseResponse::SaveUser(user_id) => Ok(user_id)) + } + + async fn games(&self) -> Result, FatalError> { + send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) + } + + async fn game(&self, game_id: &GameId) -> Result, FatalError> { + send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game)) + } + + async fn save_game( + &self, + game_id: Option, + user_id: &UserId, + game_type: &str, + game_name: &str, + ) -> Result { + send_request!(self, Request::SaveGame(game_id, user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id)) + } + + async fn character(&self, id: &CharacterId) -> Result, FatalError> { + send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) + } + + async fn session(&self, id: &SessionId) -> Result, FatalError> { + send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row)) + } + + async fn create_session(&self, id: &UserId) -> Result { + send_request!(self, Request::CreateSession(id.to_owned()), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) + } +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + use cool_asserts::assert_matches; + use disk_db::DiskDb; + use types::GameId; + + use super::*; + + const SOREN: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; + + fn setup_db() -> (DiskDb, GameId) { + let no_path: Option = None; + let db = DiskDb::new(no_path).unwrap(); + + db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true) + .unwrap(); + let game_id = db.save_game(None, &UserId::from("admin"), "Candela", "Circle of the Winter Solstice").unwrap(); + (db, game_id) + } + + #[test] + fn it_can_retrieve_a_character() { + let (db, game_id) = setup_db(); + + assert_matches!(db.character(CharacterId::from("1")), Ok(None)); + + let js: serde_json::Value = serde_json::from_str(SOREN).unwrap(); + let soren_id = db.save_character(None, game_id, js.clone()).unwrap(); + assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); + } + + #[tokio::test] + async fn it_can_retrieve_a_character_through_conn() { + let memory_db: Option = None; + let mut conn = DbConn::new(memory_db); + + assert_matches!(conn.character(&CharacterId::from("1")).await, Ok(None)); + } +} diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs new file mode 100644 index 0000000..6a24996 --- /dev/null +++ b/visions/server/src/database/types.rs @@ -0,0 +1,200 @@ +use std::fmt; + +use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] +pub struct UserId(String); + +impl UserId { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for UserId { + fn default() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } +} + +impl From<&str> for UserId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for UserId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for UserId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +impl fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] +pub struct SessionId(String); + +impl SessionId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for SessionId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for SessionId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for SessionId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] +pub struct GameId(String); + +impl GameId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for GameId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for GameId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for GameId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] +pub struct CharacterId(String); + +impl CharacterId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for CharacterId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for CharacterId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for CharacterId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug)] +pub struct UserRow { + pub id: UserId, + pub name: String, + pub password: String, + pub admin: bool, + pub enabled: bool, +} + +#[derive(Clone, Debug)] +pub struct Role { + userid: UserId, + gameid: GameId, + role: String, +} + +#[derive(Clone, Debug)] +pub struct GameRow { + pub id: GameId, + pub gm: UserId, + pub game_type: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct CharsheetRow { + pub id: String, + pub game: GameId, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug)] +pub struct SessionRow { + id: SessionId, + user_id: SessionId, +} + + + diff --git a/visions/server/src/handlers/game_management.rs b/visions/server/src/handlers/game_management.rs new file mode 100644 index 0000000..9e72bc7 --- /dev/null +++ b/visions/server/src/handlers/game_management.rs @@ -0,0 +1,32 @@ +use axum::{ + http::{HeaderMap, StatusCode}, + Json, +}; +use result_extended::ResultExt; +use serde::{Deserialize, Serialize}; + +use crate::{ + core::Core, + database::GameId, + types::{AppError, FatalError}, +}; + +use super::auth_required; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct CreateGameRequest { + pub game_type: String, + pub game_name: String, +} + +pub async fn create_game( + core: Core, + headers: HeaderMap, + req: CreateGameRequest, +) -> ResultExt { + auth_required(core.clone(), headers, |user| async move { + core.create_game(&user.id, &req.game_type, &req.game_name) + .await + }) + .await +} diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers/mod.rs similarity index 74% rename from visions/server/src/handlers.rs rename to visions/server/src/handlers/mod.rs index 4392f3a..19950cd 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers/mod.rs @@ -1,73 +1,60 @@ -use std::future::Future; +mod game_management; +mod user_management; +use axum::{http::StatusCode, Json}; +use futures::Future; +pub use game_management::*; +use typeshare::typeshare; +pub use user_management::*; -use futures::{SinkExt, StreamExt}; -use result_extended::{error, ok, return_error, ResultExt}; +use result_extended::ResultExt; use serde::{Deserialize, Serialize}; -use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; use crate::{ - asset_db::AssetId, core::Core, - database::{CharacterId, UserId}, types::{AppError, FatalError}, }; -/* -pub async fn handle_auth( - auth_ctx: &AuthDB, - auth_token: AuthToken, -) -> Result, Error> { - match auth_ctx.authenticate(auth_token).await { - Ok(Some(session)) => match serde_json::to_string(&session) { - Ok(session_token) => Response::builder() - .status(StatusCode::OK) - .body(session_token), - Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body("".to_owned()), - }, - Ok(None) => Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("".to_owned()), - Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body("".to_owned()), - } +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct HealthCheck { + pub admin_enabled: bool, } -*/ -pub async fn handler(f: F) -> impl Reply +pub async fn wrap_handler(f: F) -> (StatusCode, Json>) where - F: Future>, AppError, FatalError>>, + F: FnOnce() -> Fut, + Fut: Future>, { - match f.await { - ResultExt::Ok(response) => response, - ResultExt::Err(AppError::NotFound(_)) => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(vec![]) - .unwrap(), - ResultExt::Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(vec![]) - .unwrap(), + match f().await { + ResultExt::Ok(val) => (StatusCode::OK, Json(Some(val))), + ResultExt::Err(AppError::BadRequest) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Err(AppError::CouldNotCreateObject) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Err(AppError::NotFound(_)) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(AppError::Inaccessible(_)) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(AppError::PermissionDenied) => (StatusCode::FORBIDDEN, Json(None)), + ResultExt::Err(AppError::AuthFailed) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(AppError::JsonError(_)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(None)), + ResultExt::Err(AppError::UnexpectedError(_)) => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(None)) + } + ResultExt::Err(AppError::UsernameUnavailable) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(err) => { - panic!("Shutting down with fatal error: {:?}", err); + panic!("The server encountered a fatal error: {}", err); } } } -pub async fn handle_server_status(core: Core) -> impl Reply { - handler(async move { - let status = return_error!(core.status().await); - ok(Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Content-Type", "application/json") - .body(serde_json::to_vec(&status).unwrap()) - .unwrap()) - }) - .await +pub async fn healthcheck(core: Core) -> Vec { + match core.status().await { + ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { + admin_enabled: s.admin_enabled, + }) + .unwrap(), + ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { admin_enabled: false }).unwrap(), + ResultExt::Fatal(err) => panic!("{}", err), + } } +/* pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply { handler(async move { let (mime, bytes) = return_error!(core.get_asset(asset_id).await); @@ -125,7 +112,7 @@ pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> im pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Reply { handler(async move { - core.unregister_client(client_id); + core.unregister_client(client_id).await; ok(Response::builder() .status(StatusCode::NO_CONTENT) @@ -181,7 +168,9 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl .await } -pub async fn handle_get_users(core: Core) -> impl Reply { +pub async fn handle_get_users(core: Core) -> Response> { + unimplemented!() + /* handler(async move { let users = match core.list_users().await { ResultExt::Ok(users) => users, @@ -196,6 +185,7 @@ pub async fn handle_get_users(core: Core) -> impl Reply { .unwrap()) }) .await + */ } pub async fn handle_get_games(core: Core) -> impl Reply { @@ -255,3 +245,4 @@ pub async fn handle_set_admin_password(core: Core, password: String) -> impl Rep }) .await } +*/ diff --git a/visions/server/src/handlers/user_management.rs b/visions/server/src/handlers/user_management.rs new file mode 100644 index 0000000..dcbad23 --- /dev/null +++ b/visions/server/src/handlers/user_management.rs @@ -0,0 +1,136 @@ +use axum::{ + http::{HeaderMap, StatusCode}, + Json, +}; +use futures::Future; +use result_extended::{error, ok, return_error, ResultExt}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::{ + core::Core, + database::{SessionId, UserId}, + types::{AppError, FatalError, User, UserProfile}, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[typeshare] +pub struct AuthRequest { + pub username: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct CreateUserRequest { + pub username: String, +} + +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct SetPasswordRequest { + pub password_1: String, + pub password_2: String, +} + +async fn check_session( + core: &Core, + headers: HeaderMap, +) -> ResultExt, AppError, FatalError> { + match headers.get("Authorization") { + Some(token) => { + match token + .to_str() + .unwrap() + .split(" ") + .collect::>() + .as_slice() + { + [_schema, token] => core.session(&SessionId::from(token.to_owned())).await, + _ => error(AppError::BadRequest), + } + } + None => ok(None), + } +} + +pub async fn auth_required( + core: Core, + headers: HeaderMap, + f: F, +) -> ResultExt +where + F: FnOnce(User) -> Fut, + Fut: Future>, +{ + match return_error!(check_session(&core, headers).await) { + Some(user) => f(user).await, + None => error(AppError::AuthFailed), + } +} + +pub async fn admin_required( + core: Core, + headers: HeaderMap, + f: F, +) -> ResultExt +where + F: FnOnce(User) -> Fut, + Fut: Future>, +{ + match return_error!(check_session(&core, headers).await) { + Some(user) => { + if user.admin { + f(user).await + } else { + error(AppError::PermissionDenied) + } + } + None => error(AppError::AuthFailed), + } +} + +pub async fn check_password( + core: Core, + req: Json, +) -> ResultExt { + let Json(AuthRequest { username, password }) = req; + core.auth(&username, &password).await +} + +pub async fn get_user( + core: Core, + headers: HeaderMap, + user_id: Option, +) -> ResultExt, AppError, FatalError> { + auth_required(core.clone(), headers, |user| async move { + match user_id { + Some(user_id) => core.user(user_id).await, + None => core.user(user.id).await, + } + }).await +} + +pub async fn create_user( + core: Core, + headers: HeaderMap, + req: CreateUserRequest, +) -> ResultExt { + admin_required(core.clone(), headers, |_admin| async { + core.create_user(&req.username).await + }).await +} + +pub async fn set_password( + core: Core, + headers: HeaderMap, + req: SetPasswordRequest, +) -> ResultExt<(), AppError, FatalError> { + auth_required(core.clone(), headers, |user| async { + if req.password_1 == req.password_2 { + core.set_password(user.id, req.password_1).await + } else { + error(AppError::BadRequest) + } + }).await +} diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 894e2cc..71032a2 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -1,223 +1,33 @@ -use std::{ - convert::Infallible, - net::{IpAddr, Ipv4Addr, SocketAddr}, - path::PathBuf, -}; +use core::Core; +use std::path::PathBuf; -use asset_db::{AssetId, FsAssets}; -use authdb::AuthError; +use asset_db::FsAssets; use database::DbConn; -use handlers::{ - handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest -}; -use warp::{ - // header, - http::{Response, StatusCode}, - reply::Reply, - Filter, -}; mod asset_db; mod core; mod database; mod handlers; +mod routes; mod types; -#[derive(Debug)] -struct Unauthorized; -impl warp::reject::Reject for Unauthorized {} - -#[derive(Debug)] -struct AuthDBError(AuthError); -impl warp::reject::Reject for AuthDBError {} - -/* -fn with_session( - auth_ctx: Arc, -) -> impl Filter + Clone { - header("authentication").and_then({ - move |value: String| { - let auth_ctx = auth_ctx.clone(); - async move { - match auth_ctx.validate_session(SessionToken::from(value)).await { - Ok(Some(username)) => Ok(username), - Ok(None) => Err(warp::reject::custom(Unauthorized)), - Err(err) => Err(warp::reject::custom(AuthDBError(err))), - } - } - } - }) -} - -fn route_echo_unauthenticated() -> impl Filter + Clone { - warp::path!("api" / "v1" / "echo" / String).map(|param: String| { - println!("param: {}", param); - warp::reply::json(&vec!["unauthenticated", param.as_str()]) - }) -} - -fn route_authenticate( - auth_ctx: Arc, -) -> impl Filter + Clone { - let auth_ctx = auth_ctx.clone(); - warp::path!("api" / "v1" / "auth") - .and(warp::post()) - .and(warp::body::json()) - .map(move |param: AuthToken| { - let res = handle_auth(&auth_ctx, param.clone()); - warp::reply::json(¶m) - }) -} - -fn route_echo_authenticated( - auth_ctx: Arc, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "echo" / String) - .and(with_session(auth_ctx.clone())) - .map(move |param: String, username: Username| { - println!("param: {:?}", username); - println!("param: {}", param); - warp::reply::json(&vec!["authenticated", username.as_str(), param.as_str()]) - }) -} -*/ - -async fn handle_rejection(err: warp::Rejection) -> Result { - println!("handle_rejection: {:?}", err); - if let Some(Unauthorized) = err.find() { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::UNAUTHORIZED, - )) - } else { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::INTERNAL_SERVER_ERROR, - )) - } -} - #[tokio::main] pub async fn main() { + /* + pretty_env_logger::init(); + + let unauthenticated_endpoints = route_healthcheck().or(route_authenticate(core.clone())); + let authenticated_endpoints = route_image(core.clone()); + */ + let conn = DbConn::new(Some("/home/savanni/game.db")); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let log = warp::log("visions::api"); + let app = routes::routes(core); - let route_server_status = warp::path!("api" / "v1" / "status") - .and(warp::get()) - .then({ - let core = core.clone(); - move || handle_server_status(core.clone()) - }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:8001") + .await + .unwrap(); - let route_image = warp::path!("api" / "v1" / "image" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |file_name| handle_file(core.clone(), AssetId::from(file_name)) - }); - - let route_available_images = warp::path!("api" / "v1" / "image").and(warp::get()).then({ - let core = core.clone(); - move || handle_available_images(core.clone()) - }); - - let route_register_client = warp::path!("api" / "v1" / "client") - .and(warp::post()) - .then({ - let core = core.clone(); - move || handle_register_client(core.clone(), RegisterRequest {}) - }); - - let route_unregister_client = warp::path!("api" / "v1" / "client" / String) - .and(warp::delete()) - .then({ - let core = core.clone(); - move |client_id| handle_unregister_client(core.clone(), client_id) - }); - - let route_websocket = warp::path("ws") - .and(warp::ws()) - .and(warp::path::param()) - .then({ - let core = core.clone(); - move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id) - }); - - let route_set_bg_image_options = warp::path!("api" / "v1" / "tabletop" / "bg_image") - .and(warp::options()) - .map({ - move || { - Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "PUT") - .header("Access-Control-Allow-Headers", "content-type") - .header("Content-Type", "application/json") - .body("") - .unwrap() - } - }); - let route_set_bg_image = warp::path!("api" / "v1" / "tabletop" / "bg_image") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_background_image(core.clone(), body) - }) - .with(log); - - let route_get_users = warp::path!("api" / "v1" / "users") - .and(warp::get()) - .then({ - let core = core.clone(); - move || handle_get_users(core.clone()) - }); - - let route_get_charsheet = warp::path!("api" / "v1" / "charsheet" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |charid| handle_get_charsheet(core.clone(), charid) - }); - - let route_set_admin_password_options = warp::path!("api" / "v1" / "admin_password") - .and(warp::options()) - .map({ - move || { - Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "PUT") - .header("Access-Control-Allow-Headers", "content-type") - .header("Content-Type", "application/json") - .body("") - .unwrap() - } - }); - let route_set_admin_password = warp::path!("api" / "v1" / "admin_password") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_admin_password(core.clone(), body) - }); - - let filter = route_server_status - .or(route_register_client) - .or(route_unregister_client) - .or(route_websocket) - .or(route_image) - .or(route_available_images) - .or(route_set_bg_image_options) - .or(route_set_bg_image) - .or(route_get_users) - .or(route_get_charsheet) - .or(route_set_admin_password_options) - .or(route_set_admin_password) - .recover(handle_rejection); - - let server = warp::serve(filter); - server - .run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8001)) - .await; + axum::serve(listener, app).await.unwrap(); } diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs new file mode 100644 index 0000000..7b23280 --- /dev/null +++ b/visions/server/src/routes.rs @@ -0,0 +1,382 @@ +use axum::{ + extract::Path, + http::{header::{AUTHORIZATION, CONTENT_TYPE}, HeaderMap, Method}, + routing::{get, post, put}, + Json, Router, +}; +use tower_http::cors::{Any, CorsLayer}; + +use crate::{ + core::Core, + database::UserId, + handlers::{ + check_password, create_game, create_user, get_user, healthcheck, set_password, + wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest, + }, +}; + +pub fn routes(core: Core) -> Router { + Router::new() + .route( + "/api/v1/health", + get({ + let core = core.clone(); + move || healthcheck(core) + }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(Any), + ), + ) + .route( + "/api/v1/auth", + post({ + let core = core.clone(); + move |req: Json| wrap_handler(|| check_password(core, req)) + }) + .layer( + CorsLayer::new() + .allow_methods([Method::POST]) + .allow_headers([CONTENT_TYPE]) + .allow_origin(Any), + ), + ) + .route( + // By default, just get the self user. + "/api/v1/user", + get({ + let core = core.clone(); + move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) + }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_headers([AUTHORIZATION]) + .allow_origin(Any), + ) + .put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_user(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/password", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| set_password(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/:user_id", + get({ + let core = core.clone(); + move |user_id: Path, headers: HeaderMap| { + let Path(user_id) = user_id; + wrap_handler(|| get_user(core, headers, Some(user_id))) + } + }), + ) + .route( + "/api/v1/games", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_game(core, headers, req)) + } + }), + ) +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + use axum::http::StatusCode; + use axum_test::TestServer; + use cool_asserts::assert_matches; + use result_extended::ResultExt; + + use super::*; + use crate::{ + asset_db::FsAssets, + core::Core, + database::{Database, DbConn, GameId, SessionId, UserId}, + handlers::CreateGameRequest, + types::UserProfile, + }; + + fn setup_without_admin() -> (Core, TestServer) { + let memory_db: Option = None; + let conn = DbConn::new(memory_db); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + let app = routes(core.clone()); + let server = TestServer::new(app).unwrap(); + (core, server) + } + + async fn setup_admin_enabled() -> (Core, TestServer) { + let memory_db: Option = None; + let conn = DbConn::new(memory_db); + conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) + .await + .unwrap(); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + let app = routes(core.clone()); + let server = TestServer::new(app).unwrap(); + (core, server) + } + + async fn setup_with_user() -> (Core, TestServer) { + let (core, server) = setup_admin_enabled().await; + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .put("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateUserRequest { + username: "savanni".to_owned(), + }) + .await; + response.assert_status_ok(); + + let response = server + .put("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateUserRequest { + username: "shephard".to_owned(), + }) + .await; + response.assert_status_ok(); + + (core, server) + } + + #[tokio::test] + async fn it_returns_a_healthcheck() { + let (core, server) = setup_without_admin(); + + let response = server.get("/api/v1/health").await; + response.assert_status_ok(); + let b: crate::handlers::HealthCheck = response.json(); + assert_eq!(b, crate::handlers::HealthCheck { ok: false }); + + assert_matches!( + core.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) + .await, + ResultExt::Ok(_) + ); + + let response = server.get("/api/v1/health").await; + response.assert_status_ok(); + let b: crate::handlers::HealthCheck = response.json(); + assert_eq!(b, crate::handlers::HealthCheck { ok: true }); + } + + #[tokio::test] + async fn it_authenticates_a_user() { + let (_core, server) = setup_admin_enabled().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "wrong".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "unknown".to_owned(), + password: "wrong".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + assert!(session_id.is_some()); + } + + #[tokio::test] + async fn it_returns_user_profile() { + let (_core, server) = setup_admin_enabled().await; + + let response = server.get("/api/v1/user").await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.id, UserId::from("admin")); + assert_eq!(profile.name, "admin"); + } + + #[tokio::test] + async fn an_admin_can_create_a_user() { + // All of the contents of this test are basically required for any test on individual + // users, so I moved it all into the setup code. + let (_core, _server) = setup_with_user().await; + } + + #[tokio::test] + async fn a_user_can_get_any_user_profile() { + let (core, server) = setup_with_user().await; + + let savanni = match core.user_by_username("savanni").await { + ResultExt::Ok(Some(savanni)) => savanni, + ResultExt::Ok(None) => panic!("user was not initialized"), + ResultExt::Err(err) => panic!("{:?}", err), + ResultExt::Fatal(err) => panic!("{:?}", err), + }; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get(&format!("/api/v1/user/{}", savanni.id)) + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.name, "savanni"); + + let response = server + .get("/api/v1/user/admin") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.name, "admin"); + } + + #[tokio::test] + async fn a_user_can_change_their_password() { + let (_core, server) = setup_with_user().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + let profile = response.json::>().unwrap(); + assert_eq!(profile.name, "savanni"); + + let response = server + .put("/api/v1/user/password") + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcd".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .put("/api/v1/user/password") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcd".to_owned(), + }) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + + let response = server + .put("/api/v1/user/password") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcdefg".to_owned(), + }) + .await; + response.assert_status(StatusCode::OK); + } + + #[tokio::test] + async fn a_user_can_create_a_game() { + let (_core, server) = setup_with_user().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id = response.json::>().unwrap(); + + let response = server + .put("/api/v1/games") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateGameRequest { + game_type: "Candela".to_owned(), + game_name: "Circle of the Winter Solstice".to_owned(), + }) + .await; + let _game_id = response.json::>().unwrap(); + } + + #[ignore] + #[tokio::test] + async fn gms_can_invite_others_into_a_game() { + unimplemented!(); + } +} diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index d70e387..2e892b2 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -3,30 +3,42 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use typeshare::typeshare; -use crate::{asset_db::AssetId, database::UserRow}; +use crate::{ + asset_db::AssetId, + database::{GameId, GameRow, UserId, UserRow}, +}; #[derive(Debug, Error)] pub enum FatalError { - #[error("Non-unique database key {0}")] - NonUniqueDatabaseKey(String), - - #[error("Database migrations failed {0}")] - DatabaseMigrationFailure(String), - #[error("Failed to construct a query")] ConstructQueryFailure(String), #[error("Database connection lost")] DatabaseConnectionLost, + #[error("Expected database key is missing")] + DatabaseKeyMissing, + + #[error("Database migrations failed {0}")] + DatabaseMigrationFailure(String), + #[error("Unexpected response for message")] MessageMismatch, + + #[error("Non-unique database key {0}")] + NonUniqueDatabaseKey(String), } impl result_extended::FatalError for FatalError {} #[derive(Debug, Error)] pub enum AppError { + #[error("invalid request")] + BadRequest, + + #[error("could not create an object")] + CouldNotCreateObject, + #[error("something wasn't found {0}")] NotFound(String), @@ -36,17 +48,23 @@ pub enum AppError { #[error("the requested operation is not allowed")] PermissionDenied, + #[error("the requested username/password combination was not found")] + AuthFailed, + #[error("invalid json {0}")] JsonError(serde_json::Error), #[error("wat {0}")] UnexpectedError(String), + + #[error("this username is not available")] + UsernameUnavailable, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[typeshare] -pub struct RGB { +pub struct Rgb { pub red: u32, pub green: u32, pub blue: u32, @@ -56,7 +74,7 @@ pub struct RGB { #[serde(rename_all = "camelCase")] #[typeshare] pub struct User { - pub id: String, + pub id: UserId, pub name: String, pub password: String, pub admin: bool, @@ -66,7 +84,7 @@ pub struct User { impl From for User { fn from(row: UserRow) -> Self { Self { - id: row.id.as_str().to_owned(), + id: row.id, name: row.name.to_owned(), password: row.password.to_owned(), admin: row.admin, @@ -96,14 +114,15 @@ pub struct Player { pub struct Game { pub id: String, pub name: String, - pub players: Vec, + pub gm: UserId, + pub players: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] #[typeshare] pub struct Tabletop { - pub background_color: RGB, + pub background_color: Rgb, pub background_image: Option, } @@ -114,4 +133,33 @@ pub enum Message { UpdateTabletop(Tabletop), } +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct UserProfile { + pub id: UserId, + pub name: String, + pub games: Vec, + pub is_admin: bool, +} +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct GameOverview { + pub id: GameId, + pub game_type: String, + pub game_name: String, + pub gm: UserId, + pub players: Vec, +} + +impl From for GameOverview { + fn from(row: GameRow) -> Self { + Self { + id: row.id, + gm: row.gm, + game_type: row.game_type, + game_name: row.name, + players: vec![], + } + } +} diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index 4e26c74..b6dcf0e 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -1,64 +1,64 @@ -import React, { PropsWithChildren, useContext, useEffect, useState } from 'react'; -import './App.css'; -import { Client } from './client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { DesignPage } from './views/Design/Design'; -import { GmView } from './views/GmView/GmView'; -import { WebsocketProvider } from './components/WebsocketProvider'; -import { PlayerView } from './views/PlayerView/PlayerView'; -import { Admin } from './views/Admin/Admin'; -import Candela from './plugins/Candela'; -import { Authentication } from './views/Authentication/Authentication'; -import { StateContext, StateProvider } from './providers/StateProvider/StateProvider'; +import React, { PropsWithChildren, useContext, useEffect, useState } from 'react' +import './App.css' +import { Client } from './client' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { DesignPage } from './views/Design/Design' +import { Admin } from './views/Admin/Admin' +import Candela from './plugins/Candela' +import { Authentication } from './views/Authentication/Authentication' +import { StateContext, StateProvider } from './providers/StateProvider/StateProvider' +import { MainView } from './views' -const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; +const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803" interface AppProps { - client: Client; + client: Client } const CandelaCharsheet = ({ client }: { client: Client }) => { - let [sheet, setSheet] = useState(undefined); + let [sheet, setSheet] = useState(undefined) useEffect( - () => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)); }, + () => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)) }, [client, setSheet] - ); + ) return sheet ? :

} interface AuthedViewProps { - client: Client; + client: Client } const AuthedView = ({ client, children }: PropsWithChildren) => { - const [state, manager] = useContext(StateContext); + const [state, manager] = useContext(StateContext) return ( { - manager.setAdminPassword(password); - }} onAuth={(username, password) => console.log(username, password)}> + manager.setAdminPassword(password) + }} onAuth={(username, password) => manager.auth(username, password)}> {children} - ); + ) } const App = ({ client }: AppProps) => { - console.log("rendering app"); - const [websocketUrl, setWebsocketUrl] = useState(undefined); + console.log("rendering app") + const [websocketUrl, setWebsocketUrl] = useState(undefined) - useEffect(() => { - client.registerWebsocket().then((url) => setWebsocketUrl(url)) - }, [client]); + // useEffect(() => { + // client.registerWebsocket().then((url) => setWebsocketUrl(url)) + // }, [client]) let router = createBrowserRouter([ { path: "/", - element: - }, - { - path: "/gm", - element: websocketUrl ? :
+ element: ( + + + + + + ) }, { path: "/admin", @@ -72,12 +72,12 @@ const App = ({ client }: AppProps) => { path: "/design", element: } - ]); + ]) return (
- ); + ) } -export default App; +export default App diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 274ec77..af8815c 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -1,9 +1,12 @@ +import { SessionId, UserId, UserProfile } from "visions-types"; + export type PlayingField = { backgroundImage: string; } export class Client { private base: URL; + private sessionId: string | undefined; constructor() { this.base = new URL("http://localhost:8001"); @@ -64,9 +67,35 @@ export class Client { return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); } - async status() { + async auth(username: string, password: string): Promise { const url = new URL(this.base); - url.pathname = `/api/v1/status`; + url.pathname = `/api/v1/auth` + const response = await fetch(url, { + method: 'POST', + headers: [['Content-Type', 'application/json']], + body: JSON.stringify({ 'username': username, 'password': password }) + }); + const session_id: SessionId = await response.json(); + return session_id; + } + + async profile(sessionId: SessionId, userId: UserId | undefined): Promise { + const url = new URL(this.base); + if (userId) { + url.pathname = `/api/v1/user${userId}` + } else { + url.pathname = `/api/v1/user` + } + const response = await fetch(url, { + method: 'GET', + headers: [['Authorization', `Bearer ${sessionId}`]], + }); + return await response.json() + } + + async health() { + const url = new URL(this.base); + url.pathname = `/api/v1/health`; return fetch(url).then((response) => response.json()); } diff --git a/visions/ui/src/components/Profile/Profile.css b/visions/ui/src/components/Profile/Profile.css new file mode 100644 index 0000000..e69de29 diff --git a/visions/ui/src/components/Profile/Profile.tsx b/visions/ui/src/components/Profile/Profile.tsx new file mode 100644 index 0000000..dde4545 --- /dev/null +++ b/visions/ui/src/components/Profile/Profile.tsx @@ -0,0 +1,12 @@ +import { UserProfile } from 'visions-types'; + +export const ProfileElement = ({ name, games, is_admin }: UserProfile) => { + const adminNote = is_admin ?
Note: this user is an admin
: <>; + + return ( +
+

{name}

+
Games: {games.map((game) => <>{game.game_name} ({game.game_type})).join(', ')}
+ {adminNote} +
) +} diff --git a/visions/ui/src/components/Tabletop/Tabletop.tsx b/visions/ui/src/components/Tabletop/Tabletop.tsx index efa21bf..55f9c3c 100644 --- a/visions/ui/src/components/Tabletop/Tabletop.tsx +++ b/visions/ui/src/components/Tabletop/Tabletop.tsx @@ -1,9 +1,9 @@ import React, { useContext } from 'react'; import './Tabletop.css'; -import { RGB } from 'visions-types'; +import { Rgb } from 'visions-types'; interface TabletopElementProps { - backgroundColor: RGB; + backgroundColor: Rgb; backgroundUrl: URL | undefined; } diff --git a/visions/ui/src/components/index.ts b/visions/ui/src/components/index.ts index 64551ca..95a33e5 100644 --- a/visions/ui/src/components/index.ts +++ b/visions/ui/src/components/index.ts @@ -1,5 +1,6 @@ +import { ProfileElement } from './Profile/Profile' +import { SimpleGuage } from './Guages/SimpleGuage' import { ThumbnailElement } from './Thumbnail/Thumbnail' import { TabletopElement } from './Tabletop/Tabletop' -import { SimpleGuage } from './Guages/SimpleGuage' -export default { ThumbnailElement, TabletopElement, SimpleGuage } +export { ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage } diff --git a/visions/ui/src/plugins/Candela/Charsheet.tsx b/visions/ui/src/plugins/Candela/Charsheet.tsx index 362a7df..5b194aa 100644 --- a/visions/ui/src/plugins/Candela/Charsheet.tsx +++ b/visions/ui/src/plugins/Candela/Charsheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { assertNever } from '.'; +import { assertNever } from '../../utils'; import './Charsheet.css'; import { DriveGuage } from './DriveGuage/DriveGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; diff --git a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx index 109479b..f2c12fb 100644 --- a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx +++ b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { assertNever } from '.'; import { SimpleGuage } from '../../components/Guages/SimpleGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; import './CharsheetPanel.css'; import classNames from 'classnames'; +import { assertNever } from '../../utils'; interface CharsheetPanelProps { sheet: Charsheet; diff --git a/visions/ui/src/plugins/Candela/index.tsx b/visions/ui/src/plugins/Candela/index.tsx index 818120b..fefbe14 100644 --- a/visions/ui/src/plugins/Candela/index.tsx +++ b/visions/ui/src/plugins/Candela/index.tsx @@ -1,9 +1,5 @@ import { CharsheetElement } from './Charsheet'; import { CharsheetPanelElement } from './CharsheetPanel'; -export function assertNever(value: never) { - throw new Error("Unexpected value: " + value); -} - export default { CharsheetElement, CharsheetPanelElement }; diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index 8806702..6f928fa 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -1,26 +1,62 @@ import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react"; -import { Status, Tabletop } from "visions-types"; +import { SessionId, Status, Tabletop } from "visions-types"; import { Client } from "../../client"; -import { assertNever } from "../../plugins/Candela"; +import { assertNever } from "../../utils"; -type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string }; +type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", sessionId: string }; + +export enum LoadingState { + Loading, + Ready, +} type AppState = { - auth: AuthState; - tabletop: Tabletop; + state: LoadingState, + auth: AuthState, + tabletop: Tabletop, } type Action = { type: "SetAuthState", content: AuthState }; -const initialState = (): AppState => ( - { +const initialState = (): AppState => { + let state: AppState = { + state: LoadingState.Ready, auth: { type: "NoAdmin" }, - tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } + tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }, } -); + + const sessionId = window.localStorage.getItem("sessionId") + if (sessionId) { + return { ...state, auth: { type: "Authed", sessionId } } + } else { + return state + } +} const stateReducer = (state: AppState, action: Action): AppState => { - return { ...state, auth: action.content } + switch (action.type) { + case "SetAuthState": { + return { ...state, auth: action.content } + } + /* + default: { + assertNever(action) + return state + } + */ + } +} + +export const getSessionId = (state: AppState): SessionId | undefined => { + switch (state.auth.type) { + case "NoAdmin": return undefined + case "Unauthed": return undefined + case "Authed": return state.auth.sessionId + default: { + assertNever(state.auth) + return undefined + } + } } class StateManager { @@ -35,11 +71,9 @@ class StateManager { async status() { if (!this.client || !this.dispatch) return; - const { admin_enabled } = await this.client.status(); + const { admin_enabled } = await this.client.health(); if (!admin_enabled) { this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } }); - } else { - this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } }); } } @@ -49,6 +83,16 @@ class StateManager { await this.client.setAdminPassword(password); await this.status(); } + + async auth(username: string, password: string) { + if (!this.client || !this.dispatch) return; + + let sessionId = await this.client.auth(username, password); + if (sessionId) { + window.localStorage.setItem("sessionId", sessionId); + this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId } }); + } + } } export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]); diff --git a/visions/ui/src/utils.ts b/visions/ui/src/utils.ts new file mode 100644 index 0000000..559ae58 --- /dev/null +++ b/visions/ui/src/utils.ts @@ -0,0 +1,4 @@ +export function assertNever(value: never) { + throw new Error("Unexpected value: " + value); +} + diff --git a/visions/ui/src/views/Authentication/Authentication.tsx b/visions/ui/src/views/Authentication/Authentication.tsx index 7b0e322..baf7d27 100644 --- a/visions/ui/src/views/Authentication/Authentication.tsx +++ b/visions/ui/src/views/Authentication/Authentication.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useContext, useState } from 'react'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { assertNever } from '../../plugins/Candela'; +import { LoadingState, StateContext } from '../../providers/StateProvider/StateProvider'; +import { assertNever } from '../../utils'; import './Authentication.css'; interface AuthenticationProps { @@ -17,35 +17,43 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC let [pwField, setPwField] = useState(""); let [state, _] = useContext(StateContext); - switch (state.auth.type) { - case "NoAdmin": { - return
-
-

Welcome to your new Visions VTT Instance

-

Set your admin password:

- setPwField(evt.target.value)} /> - onAdminPassword(pwField)} /> -
-
; + switch (state.state) { + case LoadingState.Loading: { + return
Loading
} - case "Unauthed": { - return
-
-

Welcome to Visions VTT

-
- setUserField(evt.target.value)} /> - setPwField(evt.target.value)} /> - onAuth(userField, pwField)} /> -
-
-
; - } - case "Authed": { - return
{children}
; - } - default: { - assertNever(state.auth); - return
; + case LoadingState.Ready: { + switch (state.auth.type) { + case "NoAdmin": { + return
+
+

Welcome to your new Visions VTT Instance

+

Set your admin password:

+ setPwField(evt.target.value)} /> + onAdminPassword(pwField)} /> +
+
; + } + case "Unauthed": { + return
+
+

Welcome to Visions VTT

+
+ setUserField(evt.target.value)} /> + setPwField(evt.target.value)} /> + onAuth(userField, pwField)} /> +
+
+
; + } + case "Authed": { + return
{children}
; + } + default: { + assertNever(state.auth); + return
; + } + } } } + } diff --git a/visions/ui/src/views/GmView/GmView.css b/visions/ui/src/views/GmView/GmView.css deleted file mode 100644 index f3b6a10..0000000 --- a/visions/ui/src/views/GmView/GmView.css +++ /dev/null @@ -1,4 +0,0 @@ -.gm-view { - display: flex; - width: 100%; -} diff --git a/visions/ui/src/views/GmView/GmView.tsx b/visions/ui/src/views/GmView/GmView.tsx deleted file mode 100644 index 948906e..0000000 --- a/visions/ui/src/views/GmView/GmView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useContext, useEffect, useState } from 'react'; -import { Client } from '../../client'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { TabletopElement } from '../../components/Tabletop/Tabletop'; -import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail'; -import { WebsocketContext } from '../../components/WebsocketProvider'; -import './GmView.css'; - -interface GmViewProps { - client: Client -} - -export const GmView = ({ client }: GmViewProps) => { - const [state, dispatch] = useContext(StateContext); - - const [images, setImages] = useState([]); - useEffect(() => { - client.availableImages().then((images) => setImages(images)); - }, [client]); - - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
- {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} -
- -
) -} - diff --git a/visions/ui/src/views/Main/Main.css b/visions/ui/src/views/Main/Main.css new file mode 100644 index 0000000..e69de29 diff --git a/visions/ui/src/views/Main/Main.tsx b/visions/ui/src/views/Main/Main.tsx new file mode 100644 index 0000000..89d69ec --- /dev/null +++ b/visions/ui/src/views/Main/Main.tsx @@ -0,0 +1,29 @@ +import { useContext, useEffect, useState } from 'react'; +import { UserProfile } from 'visions-types'; +import { Client } from '../../client'; +import { ProfileElement } from '../../components'; +import { getSessionId, StateContext, StateProvider } from '../../providers/StateProvider/StateProvider'; + +interface MainProps { + client: Client +} + +export const MainView = ({ client }: MainProps) => { + const [state, _manager] = useContext(StateContext) + const [profile, setProfile] = useState(undefined) + + const sessionId = getSessionId(state) + useEffect(() => { + if (sessionId) { + client.profile(sessionId, undefined).then((profile) => setProfile(profile)) + } + }, [sessionId, client]) + + return ( +
+
Session ID: {sessionId}
+ {profile && } +
+ ) +} + diff --git a/visions/ui/src/views/PlayerView/PlayerView.css b/visions/ui/src/views/PlayerView/PlayerView.css deleted file mode 100644 index daf3bec..0000000 --- a/visions/ui/src/views/PlayerView/PlayerView.css +++ /dev/null @@ -1,15 +0,0 @@ -.player-view { - display: flex; - width: 100%; -} - -.player-view__left-panel { - min-width: 100px; - max-width: 20%; -} - -.player-view__right-panel { - width: 25%; -} - - diff --git a/visions/ui/src/views/PlayerView/PlayerView.tsx b/visions/ui/src/views/PlayerView/PlayerView.tsx deleted file mode 100644 index 3d3304c..0000000 --- a/visions/ui/src/views/PlayerView/PlayerView.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import './PlayerView.css'; -import { WebsocketContext } from '../../components/WebsocketProvider'; -import { Client } from '../../client'; -import { TabletopElement } from '../../components/Tabletop/Tabletop'; -import Candela from '../../plugins/Candela'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; - -const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; - -interface PlayerViewProps { - client: Client; -} - -export const PlayerView = ({ client }: PlayerViewProps) => { - const [state, dispatch] = useContext(StateContext); - - const [charsheet, setCharsheet] = useState(undefined); - - useEffect( - () => { - client.charsheet(TEST_CHARSHEET_UUID).then((c) => { - setCharsheet(c) - }); - }, - [client, setCharsheet] - ); - - const backgroundColor = state.tabletop.backgroundColor; - const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - - return (
-
-
- {charsheet ? :
}
-
) -} - diff --git a/visions/ui/src/views/index.ts b/visions/ui/src/views/index.ts new file mode 100644 index 0000000..0719403 --- /dev/null +++ b/visions/ui/src/views/index.ts @@ -0,0 +1,3 @@ +import { MainView } from './Main/Main' + +export { MainView }