Refactor the API, then give the user a landing page that shows their profile #286
445
Cargo.lock
generated
445
Cargo.lock
generated
@ -130,6 +130,16 @@ version = "1.0.89"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
|
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]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@ -284,12 +294,115 @@ dependencies = [
|
|||||||
"uuid 0.4.0",
|
"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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
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]]
|
[[package]]
|
||||||
name = "az"
|
name = "az"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@ -428,9 +541,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.7.2"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
@ -642,6 +761,16 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "cookie-factory"
|
name = "cookie-factory"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -846,6 +975,21 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@ -932,6 +1076,19 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -1850,9 +2007,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
|
checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"fnv",
|
"fnv",
|
||||||
@ -1870,6 +2027,29 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.9.5"
|
version = "1.9.5"
|
||||||
@ -1882,6 +2062,12 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "0.14.30"
|
version = "0.14.30"
|
||||||
@ -1894,7 +2080,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
@ -1906,6 +2092,26 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -1913,12 +2119,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"hyper",
|
"hyper 0.14.30",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"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]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.61"
|
version = "0.1.61"
|
||||||
@ -2049,6 +2274,17 @@ version = "2.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708"
|
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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.1"
|
||||||
@ -2254,6 +2490,12 @@ dependencies = [
|
|||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@ -2440,6 +2682,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@ -2815,6 +3063,12 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.20"
|
version = "0.2.20"
|
||||||
@ -2824,6 +3078,26 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@ -3078,8 +3352,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body",
|
"http-body 0.4.6",
|
||||||
"hyper",
|
"hyper 0.14.30",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@ -3093,7 +3367,7 @@ dependencies = [
|
|||||||
"serde 1.0.210",
|
"serde 1.0.210",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper 0.1.2",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
@ -3105,6 +3379,16 @@ dependencies = [
|
|||||||
"winreg",
|
"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]]
|
[[package]]
|
||||||
name = "result-extended"
|
name = "result-extended"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -3157,6 +3441,22 @@ dependencies = [
|
|||||||
"rusqlite",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.24"
|
version = "0.1.24"
|
||||||
@ -3200,6 +3500,12 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusty-fork"
|
name = "rusty-fork"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@ -3329,6 +3635,16 @@ dependencies = [
|
|||||||
"serde 1.0.210",
|
"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]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
@ -3760,6 +4076,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -3813,6 +4135,15 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.64"
|
version = "1.0.64"
|
||||||
@ -3864,6 +4195,37 @@ dependencies = [
|
|||||||
"weezl",
|
"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]]
|
[[package]]
|
||||||
name = "timezone-testing"
|
name = "timezone-testing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -3898,9 +4260,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.40.0"
|
version = "1.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
|
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
@ -4016,6 +4378,42 @@ dependencies = [
|
|||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "tower-service"
|
name = "tower-service"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -4073,7 +4471,7 @@ dependencies = [
|
|||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"http 1.1.0",
|
"http 1.2.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@ -4287,13 +4685,15 @@ dependencies = [
|
|||||||
"async-std",
|
"async-std",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"authdb",
|
"authdb",
|
||||||
|
"axum",
|
||||||
|
"axum-test",
|
||||||
"cool_asserts",
|
"cool_asserts",
|
||||||
"futures",
|
"futures",
|
||||||
"http 1.1.0",
|
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"pretty_env_logger",
|
||||||
"result-extended",
|
"result-extended",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
@ -4302,10 +4702,10 @@ dependencies = [
|
|||||||
"thiserror 2.0.3",
|
"thiserror 2.0.3",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tower-http",
|
||||||
"typeshare",
|
"typeshare",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid 1.11.0",
|
"uuid 1.11.0",
|
||||||
"warp",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -4337,7 +4737,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"headers",
|
"headers",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"hyper",
|
"hyper 0.14.30",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
@ -4476,6 +4876,15 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
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]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -4658,6 +5067,12 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"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]]
|
[[package]]
|
||||||
name = "yansi-term"
|
name = "yansi-term"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
name = "ld-tools-devshell";
|
name = "ld-tools-devshell";
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
pkgs.cargo-nextest
|
pkgs.cargo-nextest
|
||||||
|
pkgs.cargo-watch
|
||||||
pkgs.clang
|
pkgs.clang
|
||||||
pkgs.crate2nix
|
pkgs.crate2nix
|
||||||
pkgs.glib
|
pkgs.glib
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.81.0"
|
channel = "1.81.0"
|
||||||
targets = [ "wasm32-unknown-unknown", "thumbv6m-none-eabi" ]
|
targets = [ "wasm32-unknown-unknown", "thumbv6m-none-eabi" ]
|
||||||
|
components = [ "rustfmt", "rust-analyzer", "clippy" ]
|
||||||
|
@ -9,12 +9,13 @@ edition = "2021"
|
|||||||
async-std = { version = "1.13.0" }
|
async-std = { version = "1.13.0" }
|
||||||
async-trait = { version = "0.1.83" }
|
async-trait = { version = "0.1.83" }
|
||||||
authdb = { path = "../../authdb/" }
|
authdb = { path = "../../authdb/" }
|
||||||
|
axum = { version = "0.7.9", features = [ "macros" ] }
|
||||||
futures = { version = "0.3.31" }
|
futures = { version = "0.3.31" }
|
||||||
http = { version = "1" }
|
|
||||||
include_dir = { version = "0.7.4" }
|
include_dir = { version = "0.7.4" }
|
||||||
lazy_static = { version = "1.5.0" }
|
lazy_static = { version = "1.5.0" }
|
||||||
mime = { version = "0.3.17" }
|
mime = { version = "0.3.17" }
|
||||||
mime_guess = { version = "2.0.5" }
|
mime_guess = { version = "2.0.5" }
|
||||||
|
pretty_env_logger = { version = "0.5.0" }
|
||||||
result-extended = { path = "../../result-extended" }
|
result-extended = { path = "../../result-extended" }
|
||||||
rusqlite = { version = "0.32.1" }
|
rusqlite = { version = "0.32.1" }
|
||||||
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
|
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
|
||||||
@ -23,10 +24,11 @@ serde_json = { version = "*" }
|
|||||||
thiserror = { version = "2.0.3" }
|
thiserror = { version = "2.0.3" }
|
||||||
tokio = { version = "1", features = [ "full" ] }
|
tokio = { version = "1", features = [ "full" ] }
|
||||||
tokio-stream = { version = "0.1.16" }
|
tokio-stream = { version = "0.1.16" }
|
||||||
|
tower-http = { version = "0.6.2", features = ["cors"] }
|
||||||
typeshare = { version = "1.0.4" }
|
typeshare = { version = "1.0.4" }
|
||||||
urlencoding = { version = "2.1.3" }
|
urlencoding = { version = "2.1.3" }
|
||||||
uuid = { version = "1.11.0", features = ["v4"] }
|
uuid = { version = "1.11.0", features = ["v4"] }
|
||||||
warp = { version = "0.3" }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
cool_asserts = "2.0.3"
|
cool_asserts = "2.0.3"
|
||||||
|
axum-test = "16.4.1"
|
||||||
|
@ -7,9 +7,17 @@ tasks:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
cmds:
|
cmds:
|
||||||
# - cargo watch -x 'test -- --nocapture'
|
|
||||||
- cargo watch -x 'nextest run'
|
- cargo watch -x 'nextest run'
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
cmds:
|
cmds:
|
||||||
- cargo watch -x run
|
- cargo watch -x run
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cmds:
|
||||||
|
- cargo watch -x clippy
|
||||||
|
|
||||||
|
release:
|
||||||
|
cmds:
|
||||||
|
- task lint
|
||||||
|
- cargo build --release
|
||||||
|
@ -1,14 +1,25 @@
|
|||||||
CREATE TABLE users(
|
CREATE TABLE users(
|
||||||
uuid TEXT PRIMARY KEY,
|
uuid TEXT PRIMARY KEY,
|
||||||
name TEXT,
|
name TEXT UNIQUE,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
admin BOOLEAN,
|
admin BOOLEAN,
|
||||||
enabled BOOLEAN
|
enabled BOOLEAN
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(uuid)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE games(
|
CREATE TABLE games(
|
||||||
uuid TEXT PRIMARY KEY,
|
uuid TEXT PRIMARY KEY,
|
||||||
name TEXT
|
gm TEXT,
|
||||||
|
game_type TEXT,
|
||||||
|
name TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY(gm) REFERENCES users(uuid)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE characters(
|
CREATE TABLE characters(
|
||||||
@ -28,5 +39,4 @@ CREATE TABLE roles(
|
|||||||
FOREIGN KEY(game_id) REFERENCES games(uuid)
|
FOREIGN KEY(game_id) REFERENCES games(uuid)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO users VALUES ("admin", "admin", "", true, true);
|
INSERT INTO users VALUES ('admin', 'admin', '', true, true);
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ pub enum Error {
|
|||||||
Inaccessible,
|
Inaccessible,
|
||||||
|
|
||||||
#[error("An unexpected IO error occured when retrieving an asset {0}")]
|
#[error("An unexpected IO error occured when retrieving an asset {0}")]
|
||||||
UnexpectedError(std::io::Error),
|
Unexpected(std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<std::io::Error> for Error {
|
impl From<std::io::Error> for Error {
|
||||||
@ -25,7 +25,7 @@ impl From<std::io::Error> for Error {
|
|||||||
match err.kind() {
|
match err.kind() {
|
||||||
NotFound => Error::NotFound,
|
NotFound => Error::NotFound,
|
||||||
PermissionDenied | UnexpectedEof => Error::Inaccessible,
|
PermissionDenied | UnexpectedEof => Error::Inaccessible,
|
||||||
_ => Error::UnexpectedError(err),
|
_ => Error::Unexpected(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -35,7 +35,7 @@ impl From<std::io::Error> for Error {
|
|||||||
pub struct AssetId(String);
|
pub struct AssetId(String);
|
||||||
|
|
||||||
impl AssetId {
|
impl AssetId {
|
||||||
pub fn as_str<'a>(&'a self) -> &'a str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ impl<'a> Iterator for AssetIter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait Assets {
|
pub trait Assets {
|
||||||
fn assets<'a>(&'a self) -> AssetIter<'a>;
|
fn assets(&self) -> AssetIter;
|
||||||
|
|
||||||
fn get(&self, asset_id: AssetId) -> Result<(Mime, Vec<u8>), Error>;
|
fn get(&self, asset_id: AssetId) -> Result<(Mime, Vec<u8>), Error>;
|
||||||
}
|
}
|
||||||
@ -95,7 +95,7 @@ impl FsAssets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Assets for FsAssets {
|
impl Assets for FsAssets {
|
||||||
fn assets<'a>(&'a self) -> AssetIter<'a> {
|
fn assets(&self) -> AssetIter {
|
||||||
AssetIter(self.assets.iter())
|
AssetIter(self.assets.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,9 +104,9 @@ impl Assets for FsAssets {
|
|||||||
Some(asset) => Ok(asset),
|
Some(asset) => Ok(asset),
|
||||||
None => Err(Error::NotFound),
|
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<u8> = Vec::new();
|
let mut content: Vec<u8> = Vec::new();
|
||||||
let mut file = std::fs::File::open(&path)?;
|
let mut file = std::fs::File::open(path)?;
|
||||||
file.read_to_end(&mut content)?;
|
file.read_to_end(&mut content)?;
|
||||||
Ok((mime, content))
|
Ok((mime, content))
|
||||||
}
|
}
|
||||||
|
@ -10,11 +10,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
asset_db::{self, AssetId, Assets},
|
asset_db::{self, AssetId, Assets},
|
||||||
database::{CharacterId, Database, UserId},
|
database::{CharacterId, Database, GameId, SessionId, UserId}, types::{AppError, FatalError, Game, GameOverview, Message, Rgb, Tabletop, User, UserProfile},
|
||||||
types::{AppError, FatalError, Game, Message, Tabletop, User, RGB},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb {
|
||||||
red: 0xca,
|
red: 0xca,
|
||||||
green: 0xb9,
|
green: 0xb9,
|
||||||
blue: 0xbb,
|
blue: 0xbb,
|
||||||
@ -60,8 +59,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
|
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
|
||||||
let mut state = self.0.write().await;
|
let state = self.0.write().await;
|
||||||
let admin_user = return_error!(match state.db.user(UserId::from("admin")).await {
|
let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await {
|
||||||
Ok(Some(admin_user)) => ok(admin_user),
|
Ok(Some(admin_user)) => ok(admin_user),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return ok(Status {
|
return ok(Status {
|
||||||
@ -106,19 +105,66 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_users(&self) -> ResultExt<Vec<User>, AppError, FatalError> {
|
pub async fn user_by_username(
|
||||||
let users = self.0.write().await.db.users().await;
|
&self,
|
||||||
match users {
|
username: &str,
|
||||||
Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()),
|
) -> ResultExt<Option<User>, 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),
|
Err(err) => fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_games(&self) -> ResultExt<Vec<Game>, AppError, FatalError> {
|
pub async fn list_users(&self) -> ResultExt<Vec<User>, AppError, FatalError> {
|
||||||
let games = self.0.write().await.db.games().await;
|
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<Option<UserProfile>, 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<UserId, AppError, FatalError> {
|
||||||
|
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<Vec<GameOverview>, AppError, FatalError> {
|
||||||
|
let games = self.0.read().await.db.games().await;
|
||||||
match games {
|
match games {
|
||||||
// Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()),
|
// 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<GameId, AppError, FatalError> {
|
||||||
|
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),
|
Err(err) => fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,9 +188,7 @@ impl Core {
|
|||||||
asset_db::Error::Inaccessible => {
|
asset_db::Error::Inaccessible => {
|
||||||
AppError::Inaccessible(format!("{}", asset_id))
|
AppError::Inaccessible(format!("{}", asset_id))
|
||||||
}
|
}
|
||||||
asset_db::Error::UnexpectedError(err) => {
|
asset_db::Error::Unexpected(err) => AppError::Inaccessible(format!("{}", err)),
|
||||||
AppError::Inaccessible(format!("{}", err))
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -156,7 +200,7 @@ impl Core {
|
|||||||
.asset_store
|
.asset_store
|
||||||
.assets()
|
.assets()
|
||||||
.filter_map(
|
.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()),
|
Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
@ -182,7 +226,7 @@ impl Core {
|
|||||||
id: CharacterId,
|
id: CharacterId,
|
||||||
) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
|
) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
|
||||||
let mut state = self.0.write().await;
|
let mut state = self.0.write().await;
|
||||||
let cr = state.db.character(id).await;
|
let cr = state.db.character(&id).await;
|
||||||
match cr {
|
match cr {
|
||||||
Ok(Some(row)) => ok(Some(row.data)),
|
Ok(Some(row)) => ok(Some(row.data)),
|
||||||
Ok(None) => ok(None),
|
Ok(None) => ok(None),
|
||||||
@ -200,13 +244,32 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_user(
|
||||||
|
&self,
|
||||||
|
uuid: Option<UserId>,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> ResultExt<UserId, AppError, FatalError> {
|
||||||
|
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(
|
pub async fn set_password(
|
||||||
&self,
|
&self,
|
||||||
uuid: UserId,
|
uuid: UserId,
|
||||||
password: String,
|
password: String,
|
||||||
) -> ResultExt<(), AppError, FatalError> {
|
) -> ResultExt<(), AppError, FatalError> {
|
||||||
let mut state = self.0.write().await;
|
let state = self.0.write().await;
|
||||||
let user = match state.db.user(uuid.clone()).await {
|
let user = match state.db.user(&uuid).await {
|
||||||
Ok(Some(row)) => row,
|
Ok(Some(row)) => row,
|
||||||
Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())),
|
Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())),
|
||||||
Err(err) => return fatal(err),
|
Err(err) => return fatal(err),
|
||||||
@ -220,6 +283,31 @@ impl Core {
|
|||||||
Err(err) => fatal(err),
|
Err(err) => fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn auth(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> ResultExt<SessionId, AppError, FatalError> {
|
||||||
|
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<Option<User>, 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)]
|
#[cfg(test)]
|
||||||
@ -230,12 +318,9 @@ mod test {
|
|||||||
|
|
||||||
use cool_asserts::assert_matches;
|
use cool_asserts::assert_matches;
|
||||||
|
|
||||||
use crate::{
|
use crate::{asset_db::mocks::MemoryAssets, database::DbConn};
|
||||||
asset_db::mocks::MemoryAssets,
|
|
||||||
database::{DbConn, DiskDb},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn test_core() -> Core {
|
async fn test_core() -> Core {
|
||||||
let assets = MemoryAssets::new(vec![
|
let assets = MemoryAssets::new(vec![
|
||||||
(
|
(
|
||||||
AssetId::from("asset_1"),
|
AssetId::from("asset_1"),
|
||||||
@ -265,19 +350,25 @@ mod test {
|
|||||||
]);
|
]);
|
||||||
let memory_db: Option<PathBuf> = None;
|
let memory_db: Option<PathBuf> = None;
|
||||||
let conn = DbConn::new(memory_db);
|
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)
|
Core::new(assets, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_lists_available_images() {
|
async fn it_lists_available_images() {
|
||||||
let core = test_core();
|
let core = test_core().await;
|
||||||
let image_paths = core.available_images().await;
|
let image_paths = core.available_images().await;
|
||||||
assert_eq!(image_paths.len(), 2);
|
assert_eq!(image_paths.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_retrieves_an_asset() {
|
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_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => {
|
||||||
assert_eq!(mime.type_(), mime::IMAGE);
|
assert_eq!(mime.type_(), mime::IMAGE);
|
||||||
assert_eq!(data, "abcdefg".as_bytes());
|
assert_eq!(data, "abcdefg".as_bytes());
|
||||||
@ -286,7 +377,7 @@ mod test {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_can_retrieve_the_default_tabletop() {
|
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_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
|
||||||
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
||||||
assert_eq!(background_image, None);
|
assert_eq!(background_image, None);
|
||||||
@ -295,7 +386,7 @@ mod test {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_can_change_the_tabletop_background() {
|
async fn it_can_change_the_tabletop_background() {
|
||||||
let core = test_core();
|
let core = test_core().await;
|
||||||
assert_matches!(
|
assert_matches!(
|
||||||
core.set_background_image(AssetId::from("asset_1")).await,
|
core.set_background_image(AssetId::from("asset_1")).await,
|
||||||
ResultExt::Ok(())
|
ResultExt::Ok(())
|
||||||
@ -308,7 +399,7 @@ mod test {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_sends_notices_to_clients_on_tabletop_change() {
|
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 client_id = core.register_client().await;
|
||||||
let mut receiver = core.connect_client(client_id).await;
|
let mut receiver = core.connect_client(client_id).await;
|
||||||
|
|
||||||
@ -327,4 +418,21 @@ mod test {
|
|||||||
None => panic!("receiver did not get a message"),
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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<UserId>, String, String, bool, bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct DatabaseRequest {
|
|
||||||
tx: Sender<DatabaseResponse>,
|
|
||||||
req: Request,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum DatabaseResponse {
|
|
||||||
Charsheet(Option<CharsheetRow>),
|
|
||||||
Games(Vec<GameRow>),
|
|
||||||
User(Option<UserRow>),
|
|
||||||
Users(Vec<UserRow>),
|
|
||||||
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<String> for UserId {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for UserId {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
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<String> for GameId {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for GameId {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
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<String> for CharacterId {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for CharacterId {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
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<Option<UserRow>, FatalError>;
|
|
||||||
|
|
||||||
async fn save_user(
|
|
||||||
&mut self,
|
|
||||||
user_id: Option<UserId>,
|
|
||||||
name: &str,
|
|
||||||
password: &str,
|
|
||||||
admin: bool,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<UserId, FatalError>;
|
|
||||||
|
|
||||||
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError>;
|
|
||||||
|
|
||||||
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError>;
|
|
||||||
|
|
||||||
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, 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::<usize, usize>(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<P>(path: Option<P>) -> Result<Self, FatalError>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
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<Option<UserRow>, 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<UserRow> = 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::<Result<Vec<UserRow>, rusqlite::Error>>()
|
|
||||||
.unwrap();
|
|
||||||
match &items[..] {
|
|
||||||
[] => Ok(None),
|
|
||||||
[item] => Ok(Some(item.clone())),
|
|
||||||
_ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn users(&self) -> Result<Vec<UserRow>, 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::<Result<Vec<UserRow>, rusqlite::Error>>()
|
|
||||||
.unwrap();
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_user(
|
|
||||||
&self,
|
|
||||||
user_id: Option<UserId>,
|
|
||||||
name: &str,
|
|
||||||
password: &str,
|
|
||||||
admin: bool,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<UserId, FatalError> {
|
|
||||||
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<GameId>, name: &str) -> Result<GameId, FatalError> {
|
|
||||||
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<Option<CharsheetRow>, 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<CharsheetRow> = 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::<Result<Vec<CharsheetRow>, 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<CharacterId>,
|
|
||||||
game: GameId,
|
|
||||||
character: serde_json::Value,
|
|
||||||
) -> std::result::Result<CharacterId, FatalError> {
|
|
||||||
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<DatabaseRequest>) {
|
|
||||||
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<DatabaseRequest>,
|
|
||||||
handle: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DbConn {
|
|
||||||
pub fn new<P>(path: Option<P>) -> Self
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let (tx, rx) = bounded::<DatabaseRequest>(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<Option<UserRow>, FatalError> {
|
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(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<UserId>,
|
|
||||||
name: &str,
|
|
||||||
password: &str,
|
|
||||||
admin: bool,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<UserId, FatalError> {
|
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(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<Vec<UserRow>, FatalError> {
|
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(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<Vec<GameRow>, FatalError> {
|
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(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<Option<CharsheetRow>, FatalError> {
|
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(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<PathBuf> = 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<PathBuf> = None;
|
|
||||||
let mut conn = DbConn::new(memory_db);
|
|
||||||
|
|
||||||
assert_matches!(
|
|
||||||
conn.character(CharacterId::from("1")).await,
|
|
||||||
ResultExt::Ok(None)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
386
visions/server/src/database/disk_db.rs
Normal file
386
visions/server/src/database/disk_db.rs
Normal file
@ -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<P>(path: Option<P>) -> Result<Self, FatalError>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
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<Option<UserRow>, 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<UserRow> = 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::<Result<Vec<UserRow>, 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<Option<UserRow>, 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<UserRow> = 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::<Result<Vec<UserRow>, 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<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError> {
|
||||||
|
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<Vec<UserRow>, 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::<Result<Vec<UserRow>, rusqlite::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_game(
|
||||||
|
&self,
|
||||||
|
game_id: Option<GameId>,
|
||||||
|
gm: &UserId,
|
||||||
|
game_type: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<GameId, FatalError> {
|
||||||
|
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<Vec<GameRow>, 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::<Result<Vec<GameRow>, rusqlite::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session(&self, session_id: &SessionId) -> Result<Option<UserRow>, 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<UserRow> = 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::<Result<Vec<UserRow>, 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<SessionId, FatalError> {
|
||||||
|
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<Option<CharsheetRow>, 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<CharsheetRow> = 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::<Result<Vec<CharsheetRow>, 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<CharacterId>,
|
||||||
|
game: GameId,
|
||||||
|
character: serde_json::Value,
|
||||||
|
) -> std::result::Result<CharacterId, FatalError> {
|
||||||
|
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<DatabaseRequest>) {
|
||||||
|
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!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
228
visions/server/src/database/mod.rs
Normal file
228
visions/server/src/database/mod.rs
Normal file
@ -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<GameId>, UserId, String, String),
|
||||||
|
SaveUser(Option<UserId>, String, String, bool, bool),
|
||||||
|
Session(SessionId),
|
||||||
|
User(UserId),
|
||||||
|
UserByUsername(String),
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct DatabaseRequest {
|
||||||
|
tx: Sender<DatabaseResponse>,
|
||||||
|
req: Request,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum DatabaseResponse {
|
||||||
|
Charsheet(Option<CharsheetRow>),
|
||||||
|
CreateSession(SessionId),
|
||||||
|
Games(Vec<GameRow>),
|
||||||
|
Game(Option<GameRow>),
|
||||||
|
SaveGame(GameId),
|
||||||
|
SaveUser(UserId),
|
||||||
|
Session(Option<UserRow>),
|
||||||
|
User(Option<UserRow>),
|
||||||
|
Users(Vec<UserRow>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Database: Send + Sync {
|
||||||
|
async fn users(&self) -> Result<Vec<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn user(&self, _: &UserId) -> Result<Option<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn user_by_username(&self, _: &str) -> Result<Option<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn save_user(
|
||||||
|
&self,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError>;
|
||||||
|
|
||||||
|
async fn games(&self) -> Result<Vec<GameRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn game(&self, _: &GameId) -> Result<Option<GameRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn save_game(
|
||||||
|
&self,
|
||||||
|
game_id: Option<GameId>,
|
||||||
|
gm: &UserId,
|
||||||
|
game_type: &str,
|
||||||
|
game_name: &str,
|
||||||
|
) -> Result<GameId, FatalError>;
|
||||||
|
|
||||||
|
async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DbConn {
|
||||||
|
conn: Sender<DatabaseRequest>,
|
||||||
|
handle: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbConn {
|
||||||
|
pub fn new<P>(path: Option<P>) -> Self
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
let (tx, rx) = bounded::<DatabaseRequest>(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::<DatabaseResponse>(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<Vec<UserRow>, FatalError> {
|
||||||
|
send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user(&self, uid: &UserId) -> Result<Option<UserRow>, FatalError> {
|
||||||
|
send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_by_username(&self, username: &str) -> Result<Option<UserRow>, FatalError> {
|
||||||
|
send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_user(
|
||||||
|
&self,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError> {
|
||||||
|
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<Vec<GameRow>, FatalError> {
|
||||||
|
send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn game(&self, game_id: &GameId) -> Result<Option<GameRow>, FatalError> {
|
||||||
|
send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_game(
|
||||||
|
&self,
|
||||||
|
game_id: Option<GameId>,
|
||||||
|
user_id: &UserId,
|
||||||
|
game_type: &str,
|
||||||
|
game_name: &str,
|
||||||
|
) -> Result<GameId, FatalError> {
|
||||||
|
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<Option<CharsheetRow>, FatalError> {
|
||||||
|
send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError> {
|
||||||
|
send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError> {
|
||||||
|
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<PathBuf> = 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<PathBuf> = None;
|
||||||
|
let mut conn = DbConn::new(memory_db);
|
||||||
|
|
||||||
|
assert_matches!(conn.character(&CharacterId::from("1")).await, Ok(None));
|
||||||
|
}
|
||||||
|
}
|
200
visions/server/src/database/types.rs
Normal file
200
visions/server/src/database/types.rs
Normal file
@ -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<String> for UserId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for UserId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
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<String> for SessionId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for SessionId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
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<String> for GameId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for GameId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
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<String> for CharacterId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for CharacterId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
32
visions/server/src/handlers/game_management.rs
Normal file
32
visions/server/src/handlers/game_management.rs
Normal file
@ -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<GameId, AppError, FatalError> {
|
||||||
|
auth_required(core.clone(), headers, |user| async move {
|
||||||
|
core.create_game(&user.id, &req.game_type, &req.game_name)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
@ -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::ResultExt;
|
||||||
use result_extended::{error, ok, return_error, ResultExt};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
asset_db::AssetId,
|
|
||||||
core::Core,
|
core::Core,
|
||||||
database::{CharacterId, UserId},
|
|
||||||
types::{AppError, FatalError},
|
types::{AppError, FatalError},
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||||
pub async fn handle_auth(
|
pub struct HealthCheck {
|
||||||
auth_ctx: &AuthDB,
|
pub admin_enabled: bool,
|
||||||
auth_token: AuthToken,
|
|
||||||
) -> Result<http::Response<String>, 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()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
pub async fn handler<F>(f: F) -> impl Reply
|
pub async fn wrap_handler<F, A, Fut>(f: F) -> (StatusCode, Json<Option<A>>)
|
||||||
where
|
where
|
||||||
F: Future<Output = ResultExt<Response<Vec<u8>>, AppError, FatalError>>,
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = ResultExt<A, AppError, FatalError>>,
|
||||||
{
|
{
|
||||||
match f.await {
|
match f().await {
|
||||||
ResultExt::Ok(response) => response,
|
ResultExt::Ok(val) => (StatusCode::OK, Json(Some(val))),
|
||||||
ResultExt::Err(AppError::NotFound(_)) => Response::builder()
|
ResultExt::Err(AppError::BadRequest) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
.status(StatusCode::NOT_FOUND)
|
ResultExt::Err(AppError::CouldNotCreateObject) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
.body(vec![])
|
ResultExt::Err(AppError::NotFound(_)) => (StatusCode::NOT_FOUND, Json(None)),
|
||||||
.unwrap(),
|
ResultExt::Err(AppError::Inaccessible(_)) => (StatusCode::NOT_FOUND, Json(None)),
|
||||||
ResultExt::Err(_) => Response::builder()
|
ResultExt::Err(AppError::PermissionDenied) => (StatusCode::FORBIDDEN, Json(None)),
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
ResultExt::Err(AppError::AuthFailed) => (StatusCode::UNAUTHORIZED, Json(None)),
|
||||||
.body(vec![])
|
ResultExt::Err(AppError::JsonError(_)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(None)),
|
||||||
.unwrap(),
|
ResultExt::Err(AppError::UnexpectedError(_)) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(None))
|
||||||
|
}
|
||||||
|
ResultExt::Err(AppError::UsernameUnavailable) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
ResultExt::Fatal(err) => {
|
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 {
|
pub async fn healthcheck(core: Core) -> Vec<u8> {
|
||||||
handler(async move {
|
match core.status().await {
|
||||||
let status = return_error!(core.status().await);
|
ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck {
|
||||||
ok(Response::builder()
|
admin_enabled: s.admin_enabled,
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(serde_json::to_vec(&status).unwrap())
|
|
||||||
.unwrap())
|
|
||||||
})
|
})
|
||||||
.await
|
.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 {
|
pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply {
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let (mime, bytes) = return_error!(core.get_asset(asset_id).await);
|
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 {
|
pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Reply {
|
||||||
handler(async move {
|
handler(async move {
|
||||||
core.unregister_client(client_id);
|
core.unregister_client(client_id).await;
|
||||||
|
|
||||||
ok(Response::builder()
|
ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
@ -181,7 +168,9 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_get_users(core: Core) -> impl Reply {
|
pub async fn handle_get_users(core: Core) -> Response<Vec<u8>> {
|
||||||
|
unimplemented!()
|
||||||
|
/*
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let users = match core.list_users().await {
|
let users = match core.list_users().await {
|
||||||
ResultExt::Ok(users) => users,
|
ResultExt::Ok(users) => users,
|
||||||
@ -196,6 +185,7 @@ pub async fn handle_get_users(core: Core) -> impl Reply {
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_get_games(core: Core) -> impl Reply {
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
*/
|
136
visions/server/src/handlers/user_management.rs
Normal file
136
visions/server/src/handlers/user_management.rs
Normal file
@ -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<Option<User>, AppError, FatalError> {
|
||||||
|
match headers.get("Authorization") {
|
||||||
|
Some(token) => {
|
||||||
|
match token
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(" ")
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.as_slice()
|
||||||
|
{
|
||||||
|
[_schema, token] => core.session(&SessionId::from(token.to_owned())).await,
|
||||||
|
_ => error(AppError::BadRequest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_required<F, A, Fut>(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
f: F,
|
||||||
|
) -> ResultExt<A, AppError, FatalError>
|
||||||
|
where
|
||||||
|
F: FnOnce(User) -> Fut,
|
||||||
|
Fut: Future<Output = ResultExt<A, AppError, FatalError>>,
|
||||||
|
{
|
||||||
|
match return_error!(check_session(&core, headers).await) {
|
||||||
|
Some(user) => f(user).await,
|
||||||
|
None => error(AppError::AuthFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_required<F, A, Fut>(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
f: F,
|
||||||
|
) -> ResultExt<A, AppError, FatalError>
|
||||||
|
where
|
||||||
|
F: FnOnce(User) -> Fut,
|
||||||
|
Fut: Future<Output = ResultExt<A, AppError, FatalError>>,
|
||||||
|
{
|
||||||
|
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<AuthRequest>,
|
||||||
|
) -> ResultExt<SessionId, AppError, FatalError> {
|
||||||
|
let Json(AuthRequest { username, password }) = req;
|
||||||
|
core.auth(&username, &password).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
) -> ResultExt<Option<UserProfile>, 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<UserId, AppError, FatalError> {
|
||||||
|
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
|
||||||
|
}
|
@ -1,223 +1,33 @@
|
|||||||
use std::{
|
use core::Core;
|
||||||
convert::Infallible,
|
use std::path::PathBuf;
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
path::PathBuf,
|
|
||||||
};
|
|
||||||
|
|
||||||
use asset_db::{AssetId, FsAssets};
|
use asset_db::FsAssets;
|
||||||
use authdb::AuthError;
|
|
||||||
use database::DbConn;
|
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 asset_db;
|
||||||
mod core;
|
mod core;
|
||||||
mod database;
|
mod database;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod routes;
|
||||||
mod types;
|
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<AuthDB>,
|
|
||||||
) -> impl Filter<Extract = (Username,), Error = warp::Rejection> + 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<Extract = (Json,), Error = warp::Rejection> + 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<AuthDB>,
|
|
||||||
) -> impl Filter<Extract = (Json,), Error = warp::Rejection> + 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<AuthDB>,
|
|
||||||
) -> impl Filter<Extract = (Json,), Error = warp::Rejection> + 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<impl Reply, Infallible> {
|
|
||||||
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]
|
#[tokio::main]
|
||||||
pub async fn 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 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 app = routes::routes(core);
|
||||||
let log = warp::log("visions::api");
|
|
||||||
|
|
||||||
let route_server_status = warp::path!("api" / "v1" / "status")
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:8001")
|
||||||
.and(warp::get())
|
.await
|
||||||
.then({
|
.unwrap();
|
||||||
let core = core.clone();
|
|
||||||
move || handle_server_status(core.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
let route_image = warp::path!("api" / "v1" / "image" / String)
|
axum::serve(listener, app).await.unwrap();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
382
visions/server/src/routes.rs
Normal file
382
visions/server/src/routes.rs
Normal file
@ -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<AuthRequest>| 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<CreateUserRequest>| {
|
||||||
|
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<SetPasswordRequest>| {
|
||||||
|
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<UserId>, 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<CreateGameRequest>| {
|
||||||
|
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<PathBuf> = 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<PathBuf> = 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<SessionId> = 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<SessionId> = 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<SessionId> = 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<UserProfile> = 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<SessionId> = 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<UserProfile> = 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<UserProfile> = 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<SessionId> = 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::<Option<UserProfile>>().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::<Option<SessionId>>().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::<Option<GameId>>().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gms_can_invite_others_into_a_game() {
|
||||||
|
unimplemented!();
|
||||||
|
}
|
||||||
|
}
|
@ -3,30 +3,42 @@ use serde::{Deserialize, Serialize};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::{asset_db::AssetId, database::UserRow};
|
use crate::{
|
||||||
|
asset_db::AssetId,
|
||||||
|
database::{GameId, GameRow, UserId, UserRow},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum FatalError {
|
pub enum FatalError {
|
||||||
#[error("Non-unique database key {0}")]
|
|
||||||
NonUniqueDatabaseKey(String),
|
|
||||||
|
|
||||||
#[error("Database migrations failed {0}")]
|
|
||||||
DatabaseMigrationFailure(String),
|
|
||||||
|
|
||||||
#[error("Failed to construct a query")]
|
#[error("Failed to construct a query")]
|
||||||
ConstructQueryFailure(String),
|
ConstructQueryFailure(String),
|
||||||
|
|
||||||
#[error("Database connection lost")]
|
#[error("Database connection lost")]
|
||||||
DatabaseConnectionLost,
|
DatabaseConnectionLost,
|
||||||
|
|
||||||
|
#[error("Expected database key is missing")]
|
||||||
|
DatabaseKeyMissing,
|
||||||
|
|
||||||
|
#[error("Database migrations failed {0}")]
|
||||||
|
DatabaseMigrationFailure(String),
|
||||||
|
|
||||||
#[error("Unexpected response for message")]
|
#[error("Unexpected response for message")]
|
||||||
MessageMismatch,
|
MessageMismatch,
|
||||||
|
|
||||||
|
#[error("Non-unique database key {0}")]
|
||||||
|
NonUniqueDatabaseKey(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl result_extended::FatalError for FatalError {}
|
impl result_extended::FatalError for FatalError {}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
|
#[error("invalid request")]
|
||||||
|
BadRequest,
|
||||||
|
|
||||||
|
#[error("could not create an object")]
|
||||||
|
CouldNotCreateObject,
|
||||||
|
|
||||||
#[error("something wasn't found {0}")]
|
#[error("something wasn't found {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
@ -36,17 +48,23 @@ pub enum AppError {
|
|||||||
#[error("the requested operation is not allowed")]
|
#[error("the requested operation is not allowed")]
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
|
|
||||||
|
#[error("the requested username/password combination was not found")]
|
||||||
|
AuthFailed,
|
||||||
|
|
||||||
#[error("invalid json {0}")]
|
#[error("invalid json {0}")]
|
||||||
JsonError(serde_json::Error),
|
JsonError(serde_json::Error),
|
||||||
|
|
||||||
#[error("wat {0}")]
|
#[error("wat {0}")]
|
||||||
UnexpectedError(String),
|
UnexpectedError(String),
|
||||||
|
|
||||||
|
#[error("this username is not available")]
|
||||||
|
UsernameUnavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
pub struct RGB {
|
pub struct Rgb {
|
||||||
pub red: u32,
|
pub red: u32,
|
||||||
pub green: u32,
|
pub green: u32,
|
||||||
pub blue: u32,
|
pub blue: u32,
|
||||||
@ -56,7 +74,7 @@ pub struct RGB {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: UserId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub admin: bool,
|
pub admin: bool,
|
||||||
@ -66,7 +84,7 @@ pub struct User {
|
|||||||
impl From<UserRow> for User {
|
impl From<UserRow> for User {
|
||||||
fn from(row: UserRow) -> Self {
|
fn from(row: UserRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: row.id.as_str().to_owned(),
|
id: row.id,
|
||||||
name: row.name.to_owned(),
|
name: row.name.to_owned(),
|
||||||
password: row.password.to_owned(),
|
password: row.password.to_owned(),
|
||||||
admin: row.admin,
|
admin: row.admin,
|
||||||
@ -96,14 +114,15 @@ pub struct Player {
|
|||||||
pub struct Game {
|
pub struct Game {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub players: Vec<Player>,
|
pub gm: UserId,
|
||||||
|
pub players: Vec<UserId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
pub struct Tabletop {
|
pub struct Tabletop {
|
||||||
pub background_color: RGB,
|
pub background_color: Rgb,
|
||||||
pub background_image: Option<AssetId>,
|
pub background_image: Option<AssetId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,4 +133,33 @@ pub enum Message {
|
|||||||
UpdateTabletop(Tabletop),
|
UpdateTabletop(Tabletop),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct UserProfile {
|
||||||
|
pub id: UserId,
|
||||||
|
pub name: String,
|
||||||
|
pub games: Vec<GameOverview>,
|
||||||
|
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<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GameRow> 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![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,64 +1,64 @@
|
|||||||
import React, { PropsWithChildren, useContext, useEffect, useState } from 'react';
|
import React, { PropsWithChildren, useContext, useEffect, useState } from 'react'
|
||||||
import './App.css';
|
import './App.css'
|
||||||
import { Client } from './client';
|
import { Client } from './client'
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||||
import { DesignPage } from './views/Design/Design';
|
import { DesignPage } from './views/Design/Design'
|
||||||
import { GmView } from './views/GmView/GmView';
|
import { Admin } from './views/Admin/Admin'
|
||||||
import { WebsocketProvider } from './components/WebsocketProvider';
|
import Candela from './plugins/Candela'
|
||||||
import { PlayerView } from './views/PlayerView/PlayerView';
|
import { Authentication } from './views/Authentication/Authentication'
|
||||||
import { Admin } from './views/Admin/Admin';
|
import { StateContext, StateProvider } from './providers/StateProvider/StateProvider'
|
||||||
import Candela from './plugins/Candela';
|
import { MainView } from './views'
|
||||||
import { Authentication } from './views/Authentication/Authentication';
|
|
||||||
import { StateContext, StateProvider } from './providers/StateProvider/StateProvider';
|
|
||||||
|
|
||||||
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
|
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
client: Client;
|
client: Client
|
||||||
}
|
}
|
||||||
|
|
||||||
const CandelaCharsheet = ({ client }: { client: Client }) => {
|
const CandelaCharsheet = ({ client }: { client: Client }) => {
|
||||||
let [sheet, setSheet] = useState(undefined);
|
let [sheet, setSheet] = useState(undefined)
|
||||||
useEffect(
|
useEffect(
|
||||||
() => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)); },
|
() => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)) },
|
||||||
[client, setSheet]
|
[client, setSheet]
|
||||||
);
|
)
|
||||||
|
|
||||||
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
|
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthedViewProps {
|
interface AuthedViewProps {
|
||||||
client: Client;
|
client: Client
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
|
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
|
||||||
const [state, manager] = useContext(StateContext);
|
const [state, manager] = useContext(StateContext)
|
||||||
return (
|
return (
|
||||||
<Authentication onAdminPassword={(password) => {
|
<Authentication onAdminPassword={(password) => {
|
||||||
manager.setAdminPassword(password);
|
manager.setAdminPassword(password)
|
||||||
}} onAuth={(username, password) => console.log(username, password)}>
|
}} onAuth={(username, password) => manager.auth(username, password)}>
|
||||||
{children}
|
{children}
|
||||||
</Authentication>
|
</Authentication>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = ({ client }: AppProps) => {
|
const App = ({ client }: AppProps) => {
|
||||||
console.log("rendering app");
|
console.log("rendering app")
|
||||||
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined);
|
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
client.registerWebsocket().then((url) => setWebsocketUrl(url))
|
// client.registerWebsocket().then((url) => setWebsocketUrl(url))
|
||||||
}, [client]);
|
// }, [client])
|
||||||
|
|
||||||
let router =
|
let router =
|
||||||
createBrowserRouter([
|
createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <StateProvider client={client}><AuthedView client={client}> <PlayerView client={client} /> </AuthedView> </StateProvider>
|
element: (
|
||||||
},
|
<StateProvider client={client}>
|
||||||
{
|
<AuthedView client={client}>
|
||||||
path: "/gm",
|
<MainView client={client} />
|
||||||
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <GmView client={client} /> </WebsocketProvider> : <div> </div>
|
</AuthedView>
|
||||||
|
</StateProvider>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
@ -72,12 +72,12 @@ const App = ({ client }: AppProps) => {
|
|||||||
path: "/design",
|
path: "/design",
|
||||||
element: <DesignPage />
|
element: <DesignPage />
|
||||||
}
|
}
|
||||||
]);
|
])
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
import { SessionId, UserId, UserProfile } from "visions-types";
|
||||||
|
|
||||||
export type PlayingField = {
|
export type PlayingField = {
|
||||||
backgroundImage: string;
|
backgroundImage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Client {
|
export class Client {
|
||||||
private base: URL;
|
private base: URL;
|
||||||
|
private sessionId: string | undefined;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.base = new URL("http://localhost:8001");
|
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) });
|
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async status() {
|
async auth(username: string, password: string): Promise<SessionId | undefined> {
|
||||||
const url = new URL(this.base);
|
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<UserProfile | undefined> {
|
||||||
|
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());
|
return fetch(url).then((response) => response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
0
visions/ui/src/components/Profile/Profile.css
Normal file
0
visions/ui/src/components/Profile/Profile.css
Normal file
12
visions/ui/src/components/Profile/Profile.tsx
Normal file
12
visions/ui/src/components/Profile/Profile.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { UserProfile } from 'visions-types';
|
||||||
|
|
||||||
|
export const ProfileElement = ({ name, games, is_admin }: UserProfile) => {
|
||||||
|
const adminNote = is_admin ? <div> <i>Note: this user is an admin</i> </div> : <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h1>{name}</h1>
|
||||||
|
<div>Games: {games.map((game) => <>{game.game_name} ({game.game_type})</>).join(', ')}</div>
|
||||||
|
{adminNote}
|
||||||
|
</div>)
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import './Tabletop.css';
|
import './Tabletop.css';
|
||||||
import { RGB } from 'visions-types';
|
import { Rgb } from 'visions-types';
|
||||||
|
|
||||||
interface TabletopElementProps {
|
interface TabletopElementProps {
|
||||||
backgroundColor: RGB;
|
backgroundColor: Rgb;
|
||||||
backgroundUrl: URL | undefined;
|
backgroundUrl: URL | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { ProfileElement } from './Profile/Profile'
|
||||||
|
import { SimpleGuage } from './Guages/SimpleGuage'
|
||||||
import { ThumbnailElement } from './Thumbnail/Thumbnail'
|
import { ThumbnailElement } from './Thumbnail/Thumbnail'
|
||||||
import { TabletopElement } from './Tabletop/Tabletop'
|
import { TabletopElement } from './Tabletop/Tabletop'
|
||||||
import { SimpleGuage } from './Guages/SimpleGuage'
|
|
||||||
|
|
||||||
export default { ThumbnailElement, TabletopElement, SimpleGuage }
|
export { ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage }
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { assertNever } from '.';
|
import { assertNever } from '../../utils';
|
||||||
import './Charsheet.css';
|
import './Charsheet.css';
|
||||||
import { DriveGuage } from './DriveGuage/DriveGuage';
|
import { DriveGuage } from './DriveGuage/DriveGuage';
|
||||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { assertNever } from '.';
|
|
||||||
import { SimpleGuage } from '../../components/Guages/SimpleGuage';
|
import { SimpleGuage } from '../../components/Guages/SimpleGuage';
|
||||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||||
import './CharsheetPanel.css';
|
import './CharsheetPanel.css';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { assertNever } from '../../utils';
|
||||||
|
|
||||||
interface CharsheetPanelProps {
|
interface CharsheetPanelProps {
|
||||||
sheet: Charsheet;
|
sheet: Charsheet;
|
||||||
|
@ -1,9 +1,5 @@
|
|||||||
import { CharsheetElement } from './Charsheet';
|
import { CharsheetElement } from './Charsheet';
|
||||||
import { CharsheetPanelElement } from './CharsheetPanel';
|
import { CharsheetPanelElement } from './CharsheetPanel';
|
||||||
|
|
||||||
export function assertNever(value: never) {
|
|
||||||
throw new Error("Unexpected value: " + value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { CharsheetElement, CharsheetPanelElement };
|
export default { CharsheetElement, CharsheetPanelElement };
|
||||||
|
|
||||||
|
@ -1,26 +1,62 @@
|
|||||||
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react";
|
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 { 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 = {
|
type AppState = {
|
||||||
auth: AuthState;
|
state: LoadingState,
|
||||||
tabletop: Tabletop;
|
auth: AuthState,
|
||||||
|
tabletop: Tabletop,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action = { type: "SetAuthState", content: AuthState };
|
type Action = { type: "SetAuthState", content: AuthState };
|
||||||
|
|
||||||
const initialState = (): AppState => (
|
const initialState = (): AppState => {
|
||||||
{
|
let state: AppState = {
|
||||||
|
state: LoadingState.Ready,
|
||||||
auth: { type: "NoAdmin" },
|
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 => {
|
const stateReducer = (state: AppState, action: Action): AppState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SetAuthState": {
|
||||||
return { ...state, auth: action.content }
|
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 {
|
class StateManager {
|
||||||
@ -35,11 +71,9 @@ class StateManager {
|
|||||||
async status() {
|
async status() {
|
||||||
if (!this.client || !this.dispatch) return;
|
if (!this.client || !this.dispatch) return;
|
||||||
|
|
||||||
const { admin_enabled } = await this.client.status();
|
const { admin_enabled } = await this.client.health();
|
||||||
if (!admin_enabled) {
|
if (!admin_enabled) {
|
||||||
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
|
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.client.setAdminPassword(password);
|
||||||
await this.status();
|
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)]);
|
export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]);
|
||||||
|
4
visions/ui/src/utils.ts
Normal file
4
visions/ui/src/utils.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export function assertNever(value: never) {
|
||||||
|
throw new Error("Unexpected value: " + value);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren, useContext, useState } from 'react';
|
import { PropsWithChildren, useContext, useState } from 'react';
|
||||||
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
import { LoadingState, StateContext } from '../../providers/StateProvider/StateProvider';
|
||||||
import { assertNever } from '../../plugins/Candela';
|
import { assertNever } from '../../utils';
|
||||||
import './Authentication.css';
|
import './Authentication.css';
|
||||||
|
|
||||||
interface AuthenticationProps {
|
interface AuthenticationProps {
|
||||||
@ -17,6 +17,11 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
|
|||||||
let [pwField, setPwField] = useState<string>("");
|
let [pwField, setPwField] = useState<string>("");
|
||||||
let [state, _] = useContext(StateContext);
|
let [state, _] = useContext(StateContext);
|
||||||
|
|
||||||
|
switch (state.state) {
|
||||||
|
case LoadingState.Loading: {
|
||||||
|
return <div>Loading</div>
|
||||||
|
}
|
||||||
|
case LoadingState.Ready: {
|
||||||
switch (state.auth.type) {
|
switch (state.auth.type) {
|
||||||
case "NoAdmin": {
|
case "NoAdmin": {
|
||||||
return <div className="auth">
|
return <div className="auth">
|
||||||
@ -48,4 +53,7 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
|
|||||||
return <div></div>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
.gm-view {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
@ -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<string[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
client.availableImages().then((images) => setImages(images));
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
|
||||||
return (<div className="gm-view">
|
|
||||||
<div>
|
|
||||||
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
|
||||||
</div>
|
|
||||||
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
|
|
0
visions/ui/src/views/Main/Main.css
Normal file
0
visions/ui/src/views/Main/Main.css
Normal file
29
visions/ui/src/views/Main/Main.tsx
Normal file
29
visions/ui/src/views/Main/Main.tsx
Normal file
@ -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<UserProfile | undefined>(undefined)
|
||||||
|
|
||||||
|
const sessionId = getSessionId(state)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId) {
|
||||||
|
client.profile(sessionId, undefined).then((profile) => setProfile(profile))
|
||||||
|
}
|
||||||
|
}, [sessionId, client])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Session ID: {sessionId}</div>
|
||||||
|
{profile && <ProfileElement {...profile} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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 (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
|
||||||
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
|
|
||||||
<div className="player-view__right-panel">
|
|
||||||
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
|
|
3
visions/ui/src/views/index.ts
Normal file
3
visions/ui/src/views/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { MainView } from './Main/Main'
|
||||||
|
|
||||||
|
export { MainView }
|
Loading…
Reference in New Issue
Block a user