Refactor the API, then give the user a landing page that shows their profile #286

Merged
savanni merged 23 commits from visions-refactor-api into main 2025-01-03 22:00:02 +00:00
37 changed files with 2304 additions and 1158 deletions

445
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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" ]

View File

@ -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"

View File

@ -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

View File

@ -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);

View File

@ -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))
} }

View File

@ -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),
}
}
} }

View File

@ -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)
);
}
}

View 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!(),
}
}
}
}
}

View 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));
}
}

View 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,
}

View 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
}

View File

@ -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
} }
*/

View 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
}

View File

@ -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(&param)
})
}
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;
} }

View 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!();
}
}

View File

@ -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![],
}
}
}

View File

@ -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

View File

@ -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());
} }

View 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>)
}

View File

@ -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;
} }

View File

@ -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 }

View File

@ -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';

View File

@ -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;

View File

@ -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 };

View File

@ -1,27 +1,63 @@
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 {
client: Client | undefined; client: Client | undefined;
@ -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
View File

@ -0,0 +1,4 @@
export function assertNever(value: never) {
throw new Error("Unexpected value: " + value);
}

View File

@ -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">
@ -49,3 +54,6 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
} }
} }
} }
}
}

View File

@ -1,4 +0,0 @@
.gm-view {
display: flex;
width: 100%;
}

View File

@ -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>)
}

View File

View 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>
)
}

View File

@ -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%;
}

View File

@ -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>)
}

View File

@ -0,0 +1,3 @@
import { MainView } from './Main/Main'
export { MainView }