diff --git a/.example-config.toml b/.example-config.toml deleted file mode 100644 index bae2853..0000000 --- a/.example-config.toml +++ /dev/null @@ -1,7 +0,0 @@ -[cors] -# Allowed origins for CORS requests (exact match) -allowed_origins_exact = ["http://localhost:8000"] -# Allowed origins for CORS requests (regular expression) -allowed_origins_regex = ["^http://localhost:\\d{4}$"] -# Allowed methods for CORS requests -allowed_methods = ["GET", "POST", "PUT", "DELETE"] diff --git a/.example.config.toml b/.example.config.toml new file mode 100644 index 0000000..0ea4b14 --- /dev/null +++ b/.example.config.toml @@ -0,0 +1,13 @@ +[cors] +allowed_origins_exact = ["http://localhost:8000"] +allowed_origins_regex = ["^http://localhost:\\d{4}$"] +allowed_methods = ["GET", "POST"] +allow_credentials = true + +[oauth.discord] +client_id = "DISCORD_CLIENT_ID" +redirect_uri = "http://localhost:8000/v1/oauth2/discord/callback" + +[oauth.roblox] +client_id = "ROBLOX_CLIENT_ID" +redirect_uri = "http://localhost:8000/v1/oauth2/roblox/callback" \ No newline at end of file diff --git a/.example.env b/.example.env index 25399c0..e44bb94 100644 --- a/.example.env +++ b/.example.env @@ -1,2 +1,21 @@ +# Discord OAuth2 client secret, you can get this from the Discord Developer Portal +DISCORD_CLIENT_SECRET="your_discord_client_secret" + +# Roblox OAuth2 client secret, you can get this from the Roblox Creator Portal +ROBLOX_CLIENT_SECRET="your_roblox_client_secret" + +# A 32 byte encryption key, you can generate one with `openssl rand -base64 24` +ENCRYPTION_KEY="your_encryption_key" + +# Database +DB_DRIVER='postgres' # Make sure the db name matches the one set for DbConn in src/lib.rs -ROCKET_DATABASES='{db_name={url="postgres://user:password@localhost:5432/db_name"}}' \ No newline at end of file +DB_NAME='db_name' +DB_USER='user' +DB_PASSWORD='password' +DB_HOST='localhost' +DB_PORT='5432' + +# Use the variables above to construct the connection url +DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" +ROCKET_DATABASES="{${DB_NAME}={url=\"${DATABASE_URL}\"}}" diff --git a/.gitignore b/.gitignore index 68319f8..43114f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target /.pg-dev-data -/.env /config.toml +/Cargo.lock +/.env diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index cbc5fb1..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1832 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[package]] -name = "binascii" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" - -[[package]] -name = "bitflags" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" - -[[package]] -name = "bytemuck" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" - -[[package]] -name = "cc" -version = "1.1.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3788d6ac30243803df38a3e9991cf37e41210232916d41a8222ae378f912624" -dependencies = [ - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "darling" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "devise" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" -dependencies = [ - "devise_codegen", - "devise_core", -] - -[[package]] -name = "devise_codegen" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" -dependencies = [ - "devise_core", - "quote", -] - -[[package]] -name = "devise_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" -dependencies = [ - "bitflags", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - -[[package]] -name = "diesel" -version = "2.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe8e2e68695bd615d7e4f3227c0727b151330d3e253b525086c348d055d5e" -dependencies = [ - "bitflags", - "byteorder", - "diesel_derives", - "itoa", - "pq-sys", - "r2d2", -] - -[[package]] -name = "diesel_derives" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7f2c3de51e2ba6bf2a648285696137aaf0f5f487bcbea93972fe8a364e131a4" -dependencies = [ - "diesel_table_macro_syntax", - "dsl_auto_type", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "diesel_table_macro_syntax" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" -dependencies = [ - "syn", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dsl_auto_type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607" -dependencies = [ - "darling", - "either", - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - -[[package]] -name = "errno" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "fastrand" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" - -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic 0.6.0", - "pear", - "serde", - "toml", - "uncased", - "version_check", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generator" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "glob" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" - -[[package]] -name = "hashbrown" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "indexmap" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" -dependencies = [ - "equivalent", - "hashbrown", - "serde", -] - -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "itoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.161" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" - -[[package]] -name = "linux-raw-sys" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" - -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" - -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "memchr" -version = "2.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "wasi", - "windows-sys 0.52.0", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.1.0", - "httparse", - "memchr", - "mime", - "spin", - "tokio", - "tokio-util", - "version_check", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi 0.3.9", - "libc", -] - -[[package]] -name = "object" -version = "0.36.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project-lite" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pq-sys" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc05d7ea95200187117196eee9edd0644424911821aeb28a18ce60ea0b8793" -dependencies = [ - "vcpkg", -] - -[[package]] -name = "proc-macro2" -version = "1.0.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - -[[package]] -name = "quote" -version = "1.0.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.8", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "rocket" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" -dependencies = [ - "async-stream", - "async-trait", - "atomic 0.5.3", - "binascii", - "bytes", - "either", - "figment", - "futures", - "indexmap", - "log", - "memchr", - "multer", - "num_cpus", - "parking_lot", - "pin-project-lite", - "rand", - "ref-cast", - "rocket_codegen", - "rocket_http", - "serde", - "serde_json", - "state", - "tempfile", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "ubyte", - "version_check", - "yansi", -] - -[[package]] -name = "rocket_codegen" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" -dependencies = [ - "devise", - "glob", - "indexmap", - "proc-macro2", - "quote", - "rocket_http", - "syn", - "unicode-xid", - "version_check", -] - -[[package]] -name = "rocket_cors" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfac3a1df83f8d4fc96aa41dba3b86c786417b7fc0f52ec76295df2ba781aa69" -dependencies = [ - "http 0.2.12", - "log", - "regex", - "rocket", - "unicase", - "url", -] - -[[package]] -name = "rocket_http" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" -dependencies = [ - "cookie", - "either", - "futures", - "http 0.2.12", - "hyper", - "indexmap", - "log", - "memchr", - "pear", - "percent-encoding", - "pin-project-lite", - "ref-cast", - "serde", - "smallvec", - "stable-pattern", - "state", - "time", - "tokio", - "uncased", -] - -[[package]] -name = "rocket_sync_db_pools" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f32721ed79509adac4328e97f817a8f55a47c4b64799f6fd6cc3adb6e42ff" -dependencies = [ - "diesel", - "r2d2", - "rocket", - "rocket_sync_db_pools_codegen", - "serde", - "tokio", - "version_check", -] - -[[package]] -name = "rocket_sync_db_pools_codegen" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc890925dc79370c28eb15c9957677093fdb7e8c44966d189f38cedb995ee68" -dependencies = [ - "devise", - "quote", -] - -[[package]] -name = "roops-internal-api" -version = "0.0.0" -dependencies = [ - "diesel", - "dotenvy", - "rocket", - "rocket_cors", - "rocket_sync_db_pools", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustix" -version = "0.38.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustversion" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" - -[[package]] -name = "ryu" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.214" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.214" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.132" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_spanned" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" -dependencies = [ - "serde", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "socket2" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "stable-pattern" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" -dependencies = [ - "memchr", -] - -[[package]] -name = "state" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" -dependencies = [ - "loom", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89275301d38033efb81a6e60e3497e734dfcc62571f2854bf4b16690398824c" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" -dependencies = [ - "cfg-if", - "fastrand", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - -[[package]] -name = "time" -version = "0.3.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "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.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-macros" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ubyte" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" -dependencies = [ - "serde", -] - -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "serde", - "version_check", -] - -[[package]] -name = "unicase" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" - -[[package]] -name = "unicode-bidi" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" - -[[package]] -name = "unicode-ident" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" - -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" -dependencies = [ - "memchr", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -dependencies = [ - "is-terminal", -] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "byteorder", - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 01629da..ce0efc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,16 @@ version = "0.0.0" edition = "2021" [dependencies] -diesel = { version = "2.2.4", default-features = false, features = ["postgres"] } dotenvy = { version = "0.15.7", default-features = false } -rocket = { version = "0.5.1", default-features = false, features = ["json"] } +rocket = { version = "0.5.1", default-features = false, features = ["json", "secrets"] } rocket_cors = { version = "0.6.0", default-features = false } rocket_sync_db_pools = { version = "0.1.0", default-features = false, features = ["diesel_postgres_pool"] } +base64-compat = { version = "1.0.0", default-features = false } +rand = { version = "0.8.0", default-features = false } +sha2 = { version = "0.10.7", default-features = false } +minreq = { version = "2.12.0", default-features = false, features = ["json-using-serde", "https"] } +chrono = { version = "0.4.38", features = ["serde"] } +diesel = { version = "2.1.4", features = ["postgres", "serde_json", "chrono"] } +aes-gcm = { version = "0.10.3", default-features = false } +toml = { version = "0.8.19", default-features = false } +serial_test = { version = "3.2.0", default-features = false, features = ["file_locks"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c60be5 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +## Config + +A `config.toml` file can be created in the root directory of the project. The file is used to +configure the server. + +### CORS + +The `cors` section of the config file is used to configure the CORS settings for the server. If the +request origin is not in the list of allowed origins, the server will respond with a 403 Forbidden +status code. + +If the `allowed_methods` field is set, the server will respond with a 405 Method Not Allowed status +code if the request method is not in the list of allowed methods. + +If neither the `allowed_origins_exact` nor the `allowed_origins_regex` fields are set, the server +will accept requests from any origin. + +```toml +# CORS settings (optional) +[cors] +# Allowed origins for CORS requests (exact match), default: [] +allowed_origins_exact = ["http://localhost:8000"] +# Allowed origins for CORS requests (regular expression), default: [] +allowed_origins_regex = ["^http://localhost:\\d{4}$"] +# Allowed methods for CORS requests, default: all +allowed_methods = ["GET", "POST", "PUT", "DELETE"] +# Whether to allow credentials in CORS requests, default: false +allow_credentials = true +``` + +### OAuth2 + +The `oauth2` section of the config file is used to configure the OAuth2 settings for the server. Any +sensitive data should be stored in the environment variables. + +```toml +# Discord OAuth2 settings (required) +[oauth.discord] +# Client ID (required) +client_id = "CLIENT_ID" +# Callback URL (required) +redirect_uri = "http://localhost:8000/v1/oauth2/discord/callback" + +# Roblox OAuth2 settings (required) +[oauth.roblox] +# Client ID (required) +client_id = "CLIENT_ID" +# Callback URL (required) +redirect_uri = "http://localhost:8000/v1/oauth2/roblox/callback" +``` \ No newline at end of file diff --git a/flake.nix b/flake.nix index c129450..0fc1577 100644 --- a/flake.nix +++ b/flake.nix @@ -52,8 +52,10 @@ db_name=roops-dev - export ROCKET_DATABASES='{roops={url="postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$db_name"}}' export DATABASE_URL="postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$db_name" + export ROCKET_DATABASES="{roops={url=\"$DATABASE_URL\"}}" + + alias dblens="npx dblens $DATABASE_URL" function start_dev_db { pg_ctl -D "$PGDATA" -l "$PGDATA/logfile" start @@ -63,7 +65,15 @@ pg_ctl -D "$PGDATA" -l "$PGDATA/logfile" stop } + function delete_dev_db { + stop_dev_db + + rm -rf "$PGDATA" + } + function init_dev_db { + delete_dev_db + mkdir "$PGDATA" initdb -D "$PGDATA" @@ -76,7 +86,9 @@ stop_dev_db } - ''; + + trap "delete_dev_db" EXIT + ''; buildInputs = runtimeDeps; nativeBuildInputs = buildDeps ++ devDeps ++ [ rustc ]; }; diff --git a/init_db.sh b/init_db.sh deleted file mode 100644 index b53f58a..0000000 --- a/init_db.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash - -# Check if postgres is installed -if ! command -v psql >/dev/null 2>&1; then - echo "PostgreSQL is not installed" - exit 1 -fi - -# Check if postgres is running -if ! pg_isready >/dev/null 2>&1; then - echo "PostgreSQL is not running" - exit 1 -fi - -# Check if database roops already exists -if psql -lqt | cut -d \| -f 1 | grep -qw roops; then - echo "Database roops already exists" - exit 1 -fi - -USER=$1 -PASSWORD=$2 - -# Check if user is provided -if [ -z "$USER" ]; then - echo "Invalid arguments, usage: ./init_db.sh (password)" - exit 1 -fi - -# Create a password if one wasn't passed -if [ -z "$PASSWORD" ]; then - PASSWORD=`openssl rand -base64 8` -fi - -# Check if user already exists -if psql postgres -t -c "SELECT 1 FROM pg_roles WHERE rolname='$USER'" | grep -qw 1; then - echo "User $USER already exists" - exit 1 -fi - -# Initialize the user and database -psql -q postgres <`] instance containing the encryption key. +fn get_encryption_key() -> Key { + let key = + std::env::var(ENCRYPTION_KEY).unwrap_or_else(|_| panic!("{} must be set", ENCRYPTION_KEY)); + + if key.len() != ENCRYPTION_KEY_LENGTH { + panic!( + "Encryption key must be {} bytes long", + ENCRYPTION_KEY_LENGTH + ); + } + + *Key::::from_slice(key.as_bytes()) +} + +/// Encrypts the given data using AES-256-GCM. +/// +/// This function encrypts the provided data using the AES-256-GCM algorithm and a randomly generated nonce. +/// The encrypted data and nonce are returned as base64 encoded strings. +/// +/// # Arguments +/// +/// * `data` - A byte slice of the data to be encrypted. +/// +/// # Returns +/// +/// A [`Result`] containing an [`EncryptedData`] struct on success, or a [`String`] error message on failure. +pub(crate) fn encrypt(data: &[u8]) -> Result { + let key = get_encryption_key(); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + + let encrypted_bytes = cipher + .encrypt(&nonce, data) + .map_err(|e| format!("Failed to encrypt data: {}", e))?; + + Ok(EncryptedData { + data: base64::encode(&encrypted_bytes), + nonce: base64::encode(nonce.as_slice()), + }) +} + +/// Decrypts the given encrypted data using AES-256-GCM. +/// +/// This function decrypts the provided [`EncryptedData`] using the AES-256-GCM algorithm and the nonce. +/// The decrypted data is returned as a UTF-8 string. +/// +/// # Arguments +/// +/// * `data` - A reference to the [`EncryptedData`] struct containing the encrypted data and nonce. +/// +/// # Returns +/// +/// A [`Result`] containing the decrypted data as a [`String`] on success, or a [`String`] error message on failure. +pub(crate) fn decrypt(data: &EncryptedData) -> Result { + let key = get_encryption_key(); + let cipher = Aes256Gcm::new(&key); + + // Convert the hex string to a vector of bytes + let encrypted_bytes = + base64::decode(&data.data).map_err(|e| format!("Failed to decode base64 data: {}", e))?; + let nonce = + base64::decode(&data.nonce).map_err(|e| format!("Failed to decode base64 nonce: {}", e))?; + let nonce = Nonce::::from_slice(nonce.as_slice()); + + let decrypted_bytes = cipher + .decrypt(nonce, encrypted_bytes.as_slice()) + .map_err(|e| format!("Failed to decrypt data: {}", e))?; + let decrypted_data = std::str::from_utf8(&decrypted_bytes) + .map_err(|e| format!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(decrypted_data.to_string()) +} + +#[cfg(test)] +#[serial_test::file_serial(env)] +mod tests { + use super::{decrypt, encrypt, get_encryption_key, EncryptedData}; + use crate::constants; + + const DATA: &str = "Hello, world!"; + + #[test] + fn encryption_key_is_32_bytes() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + let key = get_encryption_key(); + assert_eq!(key.as_slice().len(), 32); + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + fn encryption_key_not_set() { + let panic_result = std::panic::catch_unwind(get_encryption_key); + assert!(panic_result.is_err()); + + let panic_message = panic_result.unwrap_err(); + let panic_message = panic_message.downcast_ref::().unwrap(); + let expected_message = format!("{} must be set", constants::env::ENCRYPTION_KEY); + assert_eq!(panic_message, &expected_message); + } + + #[test] + fn encryption_key_wrong_length() { + std::env::set_var(constants::env::ENCRYPTION_KEY, "short_key"); + let panic_result = std::panic::catch_unwind(get_encryption_key); + assert!(panic_result.is_err()); + + let panic_message = panic_result.unwrap_err(); + let panic_message = panic_message.downcast_ref::().unwrap(); + let expected_message = format!( + "Encryption key must be {} bytes long", + constants::env::ENCRYPTION_KEY_LENGTH + ); + assert_eq!(panic_message, &expected_message); + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + fn encrypt_and_decrypt_data() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + let encrypted_data = encrypt(DATA.as_bytes()).unwrap(); + let decrypted_data = decrypt(&encrypted_data).unwrap(); + + assert_eq!(decrypted_data, DATA); + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + fn decrypt_with_invalid_base64_data() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + let encrypted_data = EncryptedData { + data: "valid+data".to_string(), + nonce: "valid+nonce".to_string(), + }; + let result = decrypt(&encrypted_data); + + assert!(result.is_err()); + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + fn decrypt_with_invalid_nonce() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + + let mut encrypted_data = encrypt(DATA.as_bytes()).unwrap(); + encrypted_data.nonce = "@".to_string(); + let result = decrypt(&encrypted_data); + + assert!(result.is_err()); + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..096fdf4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,252 @@ +use rocket::serde::{Deserialize, Deserializer}; +use rocket_cors::{AllowedMethods, AllowedOrigins, Cors as RocketCors, CorsOptions}; +use std::str::FromStr; + +/// Represents the configuration for the application. +#[derive(Debug)] +pub struct Config { + /// CORS fairing. + pub(crate) cors: RocketCors, + /// OAuth settings for the application. + pub(crate) oauth: OAuthTypes, +} + +/// Represents the OAuth settings for the application. +#[derive(Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub(crate) struct OAuthTypes { + /// OAuth settings for Discord. + pub(crate) discord: OAuth, + /// OAuth settings for Roblox. + pub(crate) roblox: OAuth, +} + +/// Represents the OAuth settings for a specific provider. +#[derive(Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub(crate) struct OAuth { + /// The client ID for the OAuth application. + pub(crate) client_id: String, + /// The callback URL for the OAuth application. + pub(crate) redirect_uri: String, +} + +/// Represents the CORS settings for the application. +#[derive(Debug, Default)] +struct Cors { + allowed_origins: AllowedOrigins, + /// A list of HTTP methods that are allowed. + allowed_methods: AllowedMethods, + /// Whether credentials are allowed. + allow_credentials: bool, +} + +impl Config { + /// Loads the configuration from a TOML file. + /// + /// This function reads the configuration from the `config.toml` file and + /// deserializes it into a [`Config`] struct. If the file cannot be read or + /// deserialized, it returns an error message. + /// + /// # Returns + /// + /// A [`Result`] containing a [`Config`] struct if the configuration was loaded successfully, + /// or a [`String`] error message if the configuration could not be loaded. + pub fn load(path: Option) -> Result { + let path = path.unwrap_or("config.toml".to_string()); + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read configuration file: {}", e))?; + + toml::from_str(&content).map_err(|e| format!("Failed to parse TOML configuration: {}", e)) + } +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize the configuration into a helper struct. + #[derive(Deserialize)] + #[serde(crate = "rocket::serde")] + struct RawConfig { + cors: Option, + oauth: OAuthTypes, + } + + // Deserialize the raw configuration. + let raw = RawConfig::deserialize(deserializer)?; + + // Create the CORS fairing based on the configuration. + let cors = match raw.cors { + Some(cors) => CorsOptions::default() + .allow_credentials(cors.allow_credentials) + .allowed_origins(cors.allowed_origins) + .allowed_methods(cors.allowed_methods) + .to_cors() + .expect("CORS fairing cannot be created"), + None => CorsOptions::default() + .to_cors() + .expect("CORS fairing cannot be created"), + }; + + Ok(Config { + cors, + oauth: raw.oauth, + }) + } +} + +impl<'de> Deserialize<'de> for Cors { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Define a helper struct to hold the raw fields temporarily. + #[derive(Deserialize)] + #[serde(crate = "rocket::serde")] + struct RawCorsConfig { + allowed_origins_exact: Option>, + allowed_origins_regex: Option>, + allowed_methods: Option>, + #[serde(default)] + allow_credentials: bool, + } + + // Deserialize into the helper struct. + let raw = RawCorsConfig::deserialize(deserializer)?; + + // Create AllowedOrigins using rocket_cors' API. + let allowed_origins = match (&raw.allowed_origins_exact, &raw.allowed_origins_regex) { + (None, None) => AllowedOrigins::default(), + (Some(exact), None) => AllowedOrigins::some_exact(exact), + (None, Some(regex)) => AllowedOrigins::some_regex(regex), + (Some(exact), Some(regex)) => AllowedOrigins::some(exact, regex), + }; + + // Parse the methods from the configuration. + let allowed_methods = match raw.allowed_methods { + Some(methods) => methods + .iter() + .map(|s| FromStr::from_str(s).expect("Failed to parse method")) + .collect(), + None => AllowedMethods::default(), + }; + + Ok(Cors { + allowed_origins, + allowed_methods, + allow_credentials: raw.allow_credentials, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::http::Method; + use rocket::serde::json::serde_json; + use rocket_cors::AllowedOrigins; + + #[test] + fn deserialize_cors_with_defaults() { + let cors: Cors = serde_json::from_str("{}").unwrap(); + assert_eq!(cors.allowed_origins, AllowedOrigins::default()); + assert_eq!(cors.allowed_methods, AllowedMethods::default()); + assert!(!cors.allow_credentials); + } + + #[test] + fn deserialize_cors_with_exact_origins() { + let json = r#" + { + "allowed_origins_exact": ["https://example.com"] + } + "#; + let cors: Cors = serde_json::from_str(json).unwrap(); + assert_eq!( + cors.allowed_origins, + AllowedOrigins::some_exact(&["https://example.com"]) + ); + } + + #[test] + fn deserialize_cors_with_regex_origins() { + let json = r#" + { + "allowed_origins_regex": ["^https://.*\\.example\\.com$"] + } + "#; + let cors: Cors = serde_json::from_str(json).unwrap(); + assert_eq!( + cors.allowed_origins, + AllowedOrigins::some_regex(&["^https://.*\\.example\\.com$"]) + ); + } + + #[test] + fn deserialize_cors_with_both_origins() { + let json = r#" + { + "allowed_origins_exact": ["https://example.com"], + "allowed_origins_regex": ["^https://.*\\.example\\.com$"] + } + "#; + let cors: Cors = serde_json::from_str(json).unwrap(); + assert_eq!( + cors.allowed_origins, + AllowedOrigins::some(&["https://example.com"], &["^https://.*\\.example\\.com$"]) + ); + } + + #[test] + fn deserialize_cors_with_methods() { + let json = r#" + { + "allowed_methods": ["GET"] + } + "#; + let cors: Cors = serde_json::from_str(json).unwrap(); + assert!(cors.allowed_methods.contains(&Method::Get.into())); + } + + #[test] + fn deserialize_cors_with_credentials() { + let json = r#" + { + "allow_credentials": true + } + "#; + let cors: Cors = serde_json::from_str(json).unwrap(); + assert!(cors.allow_credentials); + } + + #[test] + fn deserialize_config_with_defaults() { + let json = r#" + { + "oauth": { + "discord": { + "client_id": "discord-client-id", + "redirect_uri": "https://example.com/discord/callback" + }, + "roblox": { + "client_id": "roblox-client-id", + "redirect_uri": "https://example.com/roblox/callback" + } + } + } + "#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.oauth.discord.client_id, "discord-client-id"); + assert_eq!( + config.oauth.discord.redirect_uri, + "https://example.com/discord/callback" + ); + assert_eq!(config.oauth.roblox.client_id, "roblox-client-id"); + assert_eq!( + config.oauth.roblox.redirect_uri, + "https://example.com/roblox/callback" + ); + } +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..a68c158 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,55 @@ +//! Constants used throughout the application. + +pub(crate) const API_VERSION: &str = "v1"; + +pub(crate) mod roblox_api { + /// The URL for authorizing with the Roblox API. + pub(crate) const AUTHORIZE_URL: &str = "https://apis.roblox.com/oauth/v1/authorize"; + /// The URL for obtaining tokens from the Roblox API. + pub(crate) const TOKEN_URL: &str = "https://apis.roblox.com/oauth/v1/token"; + /// The URL for fetching the current user's information from the Roblox API. + pub(crate) const USER_URL: &str = "https://apis.roblox.com/oauth/v1/userinfo"; +} + +pub(crate) mod discord_api { + /// The URL for authorizing with the Discord API. + pub(crate) const AUTHORIZE_URL: &str = "https://discord.com/api/v10/oauth2/authorize"; + /// The URL for obtaining tokens from the Discord API. + pub(crate) const TOKEN_URL: &str = "https://discord.com/api/v10/oauth2/token"; + /// The URL for fetching the current user's information from the Discord API. + pub(crate) const USER_URL: &str = "https://discord.com/api/v10/oauth2/@me"; +} + +pub(crate) mod cookie { + /// The name of the session ID cookie. + pub(crate) const SESSION_ID: &str = "sessionid"; + /// The name of the state cookie. + pub(crate) const STATE: &str = "state"; + /// The name of the Roblox OAuth verifier cookie. + pub(crate) const OAUTH_CODE_VERIFIER: &str = "oauth_code_verifier"; +} + +pub(crate) mod env { + /// The environment variable name for the encryption key. + pub(crate) const ENCRYPTION_KEY: &str = "ENCRYPTION_KEY"; + /// The required length of the encryption key. + pub(crate) const ENCRYPTION_KEY_LENGTH: usize = 32; + /// The environment variable name for the Discord client secret. + pub(crate) const DISCORD_CLIENT_SECRET: &str = "DISCORD_CLIENT_SECRET"; + /// The environment variable name for the Roblox client secret. + pub(crate) const ROBLOX_CLIENT_SECRET: &str = "ROBLOX_CLIENT_SECRET"; +} + +#[cfg(test)] +pub(crate) mod test { + /// A test encryption key. + pub(crate) const ENCRYPTION_KEY: &str = "0123456789abcdef0123456789abcdef"; + /// A test access token. + pub(crate) const ACCESS_TOKEN: &str = "access_token"; + /// A test refresh token. + pub(crate) const REFRESH_TOKEN: &str = "refresh_token"; + /// A test scope. + pub(crate) const SCOPE: &str = "scope"; + /// A test user ID. + pub(crate) const UID: &str = "1"; +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..17a932b --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wrappers; diff --git a/src/database/wrappers/account_links/mod.rs b/src/database/wrappers/account_links/mod.rs new file mode 100644 index 0000000..3d8ee71 --- /dev/null +++ b/src/database/wrappers/account_links/mod.rs @@ -0,0 +1,302 @@ +pub(crate) mod models; +mod schema; + +use self::models::{AccountLink, NewAccountLink}; +use crate::database::wrappers::account_links::models::UpdateAccountLink; +use diesel::prelude::*; +use std::marker::PhantomData; + +/// A collection of methods for interacting with the `account_links` table. +pub(crate) struct AccountLinksDb; + +impl AccountLinksDb { + /// Inserts a new account link into the database. + /// + /// # Arguments + /// + /// * `new_account_link` - The new account link to insert. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The newly inserted [`AccountLink`], if successful, or an error if the operation failed. + pub(crate) fn insert_one( + new_account_link: NewAccountLink, + conn: &mut PgConnection, + ) -> Result { + use self::schema::account_links::dsl::{account_links, discord_uid, is_primary}; + + conn.transaction(|conn| { + // Unset the primary flag for all other account links + // if the new account link is primary + if new_account_link.is_primary { + diesel::update(account_links) + .filter(discord_uid.eq(&new_account_link.discord_uid)) + .set(is_primary.eq(false)) + .execute(conn)?; + } + + diesel::insert_into(account_links) + .values(new_account_link) + .returning(AccountLink::as_returning()) + .get_result(conn) + }) + } + + pub(crate) fn upsert_one( + new_account_link: NewAccountLink, + conn: &mut PgConnection, + ) -> Result { + conn.transaction(|conn| { + let pk = ( + AccountLinkUserId::new(new_account_link.discord_uid.clone()), + AccountLinkUserId::new(new_account_link.roblox_uid.clone()), + ); + + // Update if already exists + if Self::find_one(pk, conn).is_some() { + let pk = ( + AccountLinkUserId::new(new_account_link.discord_uid.clone()), + AccountLinkUserId::new(new_account_link.roblox_uid.clone()), + ); + let update_account_link = UpdateAccountLink::from(new_account_link); + + Self::update_one(pk, update_account_link, conn) + } else { + Self::insert_one(new_account_link, conn) + } + }) + } + + /// Finds an account link in the database by its primary key. + /// + /// # Arguments + /// + /// * `pk` - The primary key of the account link to find. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The [`AccountLink`], if found, or [`None`] if no account pink with the primary key exists. + pub(crate) fn find_one( + pk: ( + AccountLinkUserId, + AccountLinkUserId, + ), + conn: &mut PgConnection, + ) -> Option { + use self::schema::account_links::dsl::{account_links, discord_uid, roblox_uid}; + let (pk_discord_uid, pk_roblox_uid) = pk; + + account_links + .filter(discord_uid.eq(pk_discord_uid.id)) + .filter(roblox_uid.eq(pk_roblox_uid.id)) + .first::(conn) + .ok() + } + + /// Updates the account link in the database by its primary key. + /// + /// # Arguments + /// + /// * `pk` - The primary key of the account link to update. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The updated [`AccountLink`], if successful, or an error if the operation failed. + pub(crate) fn update_one( + pk: ( + AccountLinkUserId, + AccountLinkUserId, + ), + new_account_link: UpdateAccountLink, + conn: &mut PgConnection, + ) -> Result { + use self::schema::account_links::dsl::{ + account_links, discord_uid, is_primary, roblox_uid, + }; + let (pk_discord_uid, pk_roblox_uid) = pk; + + conn.transaction(|conn| { + if let Some(primary) = new_account_link.is_primary { + // Set all other account links to not primary + // if the new account link is primary + if primary { + diesel::update(account_links) + .filter(discord_uid.eq(&pk_discord_uid.id)) + .set(is_primary.eq(false)) + .execute(conn)?; + } + } + + // Set the new primary account link + diesel::update(account_links) + .filter(discord_uid.eq(pk_discord_uid.id)) + .filter(roblox_uid.eq(pk_roblox_uid.id)) + .set(new_account_link) + .returning(AccountLink::as_returning()) + .get_result(conn) + }) + } +} + +impl From for UpdateAccountLink { + fn from(new_account_link: NewAccountLink) -> Self { + Self { + is_primary: Some(new_account_link.is_primary), + } + } +} + +/// Marker for Discord users. +pub(crate) struct DiscordMarker; +/// Marker for Roblox users. +pub(crate) struct RobloxMarker; + +/// Marker trait for user types. +pub(crate) trait UserMarker { + /// The ID type for the user. + type Id; +} + +impl UserMarker for DiscordMarker { + type Id = String; +} + +impl UserMarker for RobloxMarker { + type Id = String; +} + +/// Wrapper for account link user IDs. +#[derive(Debug)] +pub(crate) struct AccountLinkUserId { + /// Marker for the user type. + marker: PhantomData, + /// The user ID. + id: Marker::Id, +} + +impl AccountLinkUserId { + /// Create a new [`AccountLinkUserId`] with the given ID. + pub(crate) fn new(id: Marker::Id) -> Self { + Self { + marker: PhantomData, + id, + } + } +} + +impl AccountLinkUserId { + /// Find the primary account link by **Discord ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// The primary [`AccountLink`] if it exists, [`None`] otherwise. + pub(crate) fn find_primary(&self, conn: &mut PgConnection) -> Option { + use self::schema::account_links::dsl::{account_links, discord_uid, is_primary}; + + account_links + .filter(discord_uid.eq(&self.id)) + .filter(is_primary.eq(true)) + .first::(conn) + .ok() + } + + /// Find all account links associated with the **Discord ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// A vector of [`AccountLink`]s associated with the **Discord ID**. + pub(crate) fn find_many(&self, conn: &mut PgConnection) -> Vec { + use self::schema::account_links::dsl::{account_links, discord_uid}; + + account_links + .filter(discord_uid.eq(&self.id)) + .load::(conn) + .unwrap_or_default() + } + + /// Delete all account links associated with the **Discord ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// `true` if the account links were successfully deleted, `false` otherwise. + pub(crate) fn delete_many(&self, conn: &mut PgConnection) -> bool { + use self::schema::account_links::dsl::{account_links, discord_uid}; + + diesel::delete(account_links) + .filter(discord_uid.eq(&self.id)) + .execute(conn) + .is_ok() + } +} + +impl AccountLinkUserId { + /// Find all account links associated with the **Roblox ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// A vector of [`AccountLink`]s associated with the **Roblox ID**. + pub(crate) fn find_many(&self, conn: &mut PgConnection) -> Vec { + use self::schema::account_links::dsl::{account_links, roblox_uid}; + + account_links + .filter(roblox_uid.eq(&self.id)) + .load::(conn) + .unwrap_or_default() + } + + /// Find the primary account link by **Roblox ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// The primary [`AccountLink`] if it exists, [`None`] otherwise. + pub(crate) fn find_primary(&self, conn: &mut PgConnection) -> Option { + use self::schema::account_links::dsl::{account_links, is_primary, roblox_uid}; + + account_links + .filter(roblox_uid.eq(&self.id)) + .filter(is_primary.eq(true)) + .first::(conn) + .ok() + } + + /// Delete all account links associated with the **Roblox ID**. + /// + /// # Arguments + /// + /// * `conn` - The database connection. + /// + /// # Returns + /// + /// `true` if the account links were successfully deleted, `false` otherwise. + pub(crate) fn delete_many(&self, conn: &mut PgConnection) -> bool { + use self::schema::account_links::dsl::{account_links, roblox_uid}; + + diesel::delete(account_links) + .filter(roblox_uid.eq(&self.id)) + .execute(conn) + .is_ok() + } +} diff --git a/src/database/wrappers/account_links/models.rs b/src/database/wrappers/account_links/models.rs new file mode 100644 index 0000000..f0e5043 --- /dev/null +++ b/src/database/wrappers/account_links/models.rs @@ -0,0 +1,102 @@ +use super::schema; +use diesel::prelude::*; + +/// Represents a link between a Discord account and a Roblox account. +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = schema::account_links)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct AccountLink { + /// The user's Roblox ID. + /// + /// Composite primary key with `discord_uid`. + pub(crate) roblox_uid: String, + /// The user's Discord ID. + /// + /// Composite primary key with `roblox_uid`. + pub(crate) discord_uid: String, + /// Whether this link is the user's primary account. + pub(crate) is_primary: bool, +} + +/// Represents a new account link to be inserted into the database. +/// See [`AccountLink`] for field definitions. +#[derive(Insertable, Debug)] +#[diesel(table_name = schema::account_links)] +pub(crate) struct NewAccountLink { + pub(crate) roblox_uid: String, + pub(crate) discord_uid: String, + pub(crate) is_primary: bool, +} + +/// Represents an update to an account link in the database. +/// See [`AccountLink`] for field definitions. +#[derive(AsChangeset, Debug)] +#[diesel(table_name = schema::account_links)] +pub(crate) struct UpdateAccountLink { + pub(crate) is_primary: Option, +} + +/// Represents an update to an account link in the database. +/// See [`AccountLink`] for field definitions. +#[derive(Debug, Default)] +pub(crate) struct AccountLinkBuilder { + roblox_uid: Option, + discord_uid: Option, + is_primary: Option, +} + +impl NewAccountLink { + /// Creates a new [`AccountLinkBuilder`] instance. + pub(crate) fn build() -> AccountLinkBuilder { + AccountLinkBuilder::default() + } +} + +impl UpdateAccountLink { + /// Creates a new [`AccountLinkBuilder`] instance. + pub(crate) fn build() -> AccountLinkBuilder { + AccountLinkBuilder::default() + } +} + +impl AccountLinkBuilder { + /// Creates a new [`AccountLinkBuilder`] instance. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Sets the Roblox UID for the account link. + pub(crate) fn roblox_uid(mut self, roblox_uid: String) -> Self { + self.roblox_uid = Some(roblox_uid); + self + } + + /// Sets the Discord UID for the account link. + pub(crate) fn discord_uid(mut self, discord_uid: String) -> Self { + self.discord_uid = Some(discord_uid); + self + } + + /// Sets whether the account link is the user's primary account. + #[allow(clippy::wrong_self_convention)] + pub(crate) fn is_primary(mut self, is_primary: bool) -> Self { + self.is_primary = Some(is_primary); + self + } + + /// Builds the [`NewAccountLink`] instance. + pub(crate) fn build(self) -> NewAccountLink { + NewAccountLink { + roblox_uid: self.roblox_uid.expect("roblox_uid is required"), + discord_uid: self.discord_uid.expect("discord_uid is required"), + is_primary: self.is_primary.expect("is_primary is required"), + } + } + + /// Builds the [`UpdateAccountLink`] instance. + pub(crate) fn build_update(self) -> UpdateAccountLink { + UpdateAccountLink { + is_primary: self.is_primary, + } + } +} diff --git a/src/database/wrappers/account_links/schema.rs b/src/database/wrappers/account_links/schema.rs new file mode 100644 index 0000000..92e3779 --- /dev/null +++ b/src/database/wrappers/account_links/schema.rs @@ -0,0 +1,15 @@ +diesel::table! { + /// Represents a link between a Discord account and a Roblox account. + account_links (discord_uid, roblox_uid) { + /// The user's Roblox ID. + /// + /// Composite primary key with `discord_uid`. + roblox_uid -> Text, + /// The user's Discord ID. + /// + /// Composite primary key with `roblox_uid`. + discord_uid -> Text, + /// Whether this link is the user's primary account. + is_primary -> Bool, + } +} diff --git a/src/database/wrappers/discord_connections/mod.rs b/src/database/wrappers/discord_connections/mod.rs new file mode 100644 index 0000000..04f99db --- /dev/null +++ b/src/database/wrappers/discord_connections/mod.rs @@ -0,0 +1,120 @@ +pub(crate) mod models; +mod schema; + +use self::models::{DiscordConnection, NewDiscordConnection}; +use crate::database::wrappers::discord_connections::models::UpdateDiscordConnection; +use diesel::prelude::*; + +/// A collection of methods for interacting with the `discord_connections` table. +pub(crate) struct DiscordConnectionsDb; + +impl DiscordConnectionsDb { + /// Inserts a new discord connection into the database. + /// + /// # Arguments + /// + /// * `new_conn` - The new discord connection to insert. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The newly inserted [`DiscordConnection`], if successful, or an error if the operation failed. + pub(crate) fn insert_one( + new_conn: NewDiscordConnection, + conn: &mut PgConnection, + ) -> Result { + use self::schema::discord_connections; + + diesel::insert_into(discord_connections::table) + .values(new_conn) + .returning(DiscordConnection::as_returning()) + .get_result(conn) + } + + /// Upserts (inserts or updates) a discord connection in the database. + /// + /// # Arguments + /// + /// * `new_conn` - The new discord connection to upsert. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The upserted [`DiscordConnection`], if successful, or an error if the operation failed. + pub(crate) fn upsert_one( + new_conn: NewDiscordConnection, + conn: &mut PgConnection, + ) -> Result { + // Update the connection if it already exists, otherwise insert a new one. + if Self::find_one(&new_conn.uid, conn).is_some() { + let uid = new_conn.uid.clone(); + let update_conn = UpdateDiscordConnection::from(new_conn); + Self::update_one(&uid, update_conn, conn) + } else { + Self::insert_one(new_conn, conn) + } + } + + /// Finds a discord connection in the database by its primary key. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the discord connection to find. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The [`DiscordConnection`], if found, or [`None`] if no connection with the primary key exists. + pub(crate) fn find_one(pk_uid: &str, conn: &mut PgConnection) -> Option { + use self::schema::discord_connections::dsl::{discord_connections, uid}; + + discord_connections + .filter(uid.eq(pk_uid)) + .first::(conn) + .ok() + } + + /// Deletes a discord connection from the database by its primary key. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the discord connection to delete. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// `true` if the operation was successful, `false` otherwise. + pub(crate) fn delete_one(pk_uid: &str, conn: &mut PgConnection) -> bool { + use self::schema::discord_connections::dsl::*; + + diesel::delete(discord_connections) + .filter(uid.eq(pk_uid)) + .execute(conn) + .is_ok() + } + + /// Updates a discord connection in the database by its primary key. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the discord connection to update. + /// * `new_conn` - The new discord connection to update. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The updated [`DiscordConnection`], if successful, or an error if the operation failed. + pub(crate) fn update_one( + pk_uid: &str, + new_conn: UpdateDiscordConnection, + conn: &mut PgConnection, + ) -> Result { + use self::schema::discord_connections::dsl::{discord_connections, uid}; + + diesel::update(discord_connections) + .filter(uid.eq(pk_uid)) + .set(new_conn) + .returning(DiscordConnection::as_returning()) + .get_result(conn) + } +} diff --git a/src/database/wrappers/discord_connections/models.rs b/src/database/wrappers/discord_connections/models.rs new file mode 100644 index 0000000..bee1353 --- /dev/null +++ b/src/database/wrappers/discord_connections/models.rs @@ -0,0 +1,252 @@ +use super::schema; +use diesel::prelude::*; + +/// Represents an authorized Discord connection in the database. +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = schema::discord_connections)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct DiscordConnection { + /// The unique identifier for the connection. + /// This is the same as the user's Discord ID. + /// + /// Primary key. + pub(crate) uid: String, + /// The user's access token. + pub(crate) access_token: String, + /// The nonce that was used to encrypt the access token. + pub(crate) access_token_nonce: String, + /// The user's refresh token. + pub(crate) refresh_token: String, + /// The nonce that was used to encrypt the refresh token. + pub(crate) refresh_token_nonce: String, + /// The time at which the access token expires. + pub(crate) expires_at: chrono::NaiveDateTime, + /// The scopes granted by the user. + pub(crate) scope: String, +} + +/// Represents a new Discord connection to be inserted into the database. +/// See [`DiscordConnection`] for field definitions. +#[derive(Insertable, Debug)] +#[diesel(table_name = schema::discord_connections)] +pub(crate) struct NewDiscordConnection { + pub(crate) uid: String, + pub(crate) access_token: String, + access_token_nonce: String, + pub(crate) refresh_token: String, + refresh_token_nonce: String, + pub(crate) expires_at: chrono::NaiveDateTime, + pub(crate) scope: String, +} + +/// Represents an update to a Discord connection in the database. +/// See [`DiscordConnection`] for field definitions. +#[derive(AsChangeset, Debug)] +#[diesel(table_name = schema::discord_connections)] +pub(crate) struct UpdateDiscordConnection { + pub(crate) access_token: Option, + access_token_nonce: Option, + pub(crate) refresh_token: Option, + refresh_token_nonce: Option, + pub(crate) expires_at: Option, + pub(crate) scope: Option, +} + +/// A builder for creating new or updating existing Discord connections. +/// See [`DiscordConnection`] for field definitions. +/// +/// This struct provides a builder pattern for constructing [`NewDiscordConnection`] and [`UpdateDiscordConnection`] instances. +#[derive(Default)] +pub(crate) struct DiscordConnectionBuilder { + uid: Option, + access_token: Option, + access_token_nonce: Option, + refresh_token: Option, + refresh_token_nonce: Option, + expires_at: Option, + scope: Option, +} + +impl NewDiscordConnection { + /// Creates a new [`DiscordConnectionBuilder`] instance. + pub(crate) fn build() -> DiscordConnectionBuilder { + DiscordConnectionBuilder::default() + } +} + +impl UpdateDiscordConnection { + /// Creates a new [`DiscordConnectionBuilder`] instance. + pub(crate) fn build() -> DiscordConnectionBuilder { + DiscordConnectionBuilder::default() + } +} + +impl DiscordConnectionBuilder { + /// Creates a new [`DiscordConnectionBuilder`] instance. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Sets the unique identifier for the connection. + /// This is the same as the user's Discord ID. + pub(crate) fn uid(mut self, uid: String) -> Self { + self.uid = Some(uid); + self + } + + /// Sets the user's access token. + pub(crate) fn access_token(mut self, access_token: String) -> Self { + self.access_token = Some(access_token); + self + } + + /// Sets the time at which the access token expires. + pub(crate) fn expires_at(mut self, expires_at: chrono::NaiveDateTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Sets the user's refresh token. + pub(crate) fn refresh_token(mut self, refresh_token: String) -> Self { + self.refresh_token = Some(refresh_token); + self + } + + /// Sets the scopes granted by the user. + pub(crate) fn scope(mut self, scope: String) -> Self { + self.scope = Some(scope); + self + } + + /// Encrypts the access and refresh tokens. + fn encrypt_tokens(&mut self) -> Result<(), String> { + // Encrypt the access token if it exists + if let Some(access_token) = &self.access_token { + let data = crate::cipher::encrypt(access_token.as_bytes())?; + + self.access_token_nonce = Some(data.nonce); + self.access_token = Some(data.data); + } + + // Encrypt the refresh token if it exists + if let Some(refresh_token) = &self.refresh_token { + let data = crate::cipher::encrypt(refresh_token.as_bytes())?; + + self.refresh_token_nonce = Some(data.nonce); + self.refresh_token = Some(data.data); + } + + Ok(()) + } + + /// Builds a new [`NewDiscordConnection`] instance. + pub(crate) fn build(mut self) -> Result { + self.encrypt_tokens()?; + + Ok(NewDiscordConnection { + uid: self.uid.expect("uid is required"), + access_token: self.access_token.expect("access_token is required"), + access_token_nonce: self + .access_token_nonce + .expect("access_token_nonce is required"), + expires_at: self.expires_at.expect("expires_at is required"), + refresh_token: self.refresh_token.expect("refresh_token is required"), + refresh_token_nonce: self + .refresh_token_nonce + .expect("refresh_token_nonce is required"), + scope: self.scope.expect("scope is required"), + }) + } + + /// Builds a new [`UpdateDiscordConnection`] instance. + pub(crate) fn build_update(mut self) -> Result { + self.encrypt_tokens()?; + + Ok(UpdateDiscordConnection { + access_token: self.access_token, + access_token_nonce: self.access_token_nonce, + expires_at: self.expires_at, + refresh_token: self.refresh_token, + refresh_token_nonce: self.refresh_token_nonce, + scope: self.scope, + }) + } +} + +impl From for UpdateDiscordConnection { + fn from(new_conn: NewDiscordConnection) -> Self { + UpdateDiscordConnection { + access_token: Some(new_conn.access_token), + access_token_nonce: Some(new_conn.access_token_nonce), + expires_at: Some(new_conn.expires_at), + refresh_token: Some(new_conn.refresh_token), + refresh_token_nonce: Some(new_conn.refresh_token_nonce), + scope: Some(new_conn.scope), + } + } +} + +#[cfg(test)] +mod tests { + use super::DiscordConnectionBuilder; + use crate::constants; + use serial_test::file_serial; + + #[test] + #[file_serial(env)] + fn test_build() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + + let now = chrono::Utc::now().naive_utc(); + let conn = DiscordConnectionBuilder::new() + .uid(constants::test::UID.to_string()) + .access_token(constants::test::ACCESS_TOKEN.to_string()) + .expires_at(now) + .refresh_token(constants::test::REFRESH_TOKEN.to_string()) + .scope(constants::test::SCOPE.to_string()) + .build() + .unwrap(); + + // The access token is encrypted, so we can't compare it directly + assert_ne!(conn.access_token, constants::test::ACCESS_TOKEN); + // The refresh token is encrypted, so we can't compare it directly + assert_ne!(conn.refresh_token, constants::test::REFRESH_TOKEN); + assert_eq!(conn.uid, constants::test::UID); + assert_eq!(conn.expires_at, now); + assert_eq!(conn.scope, constants::test::SCOPE); + + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + #[file_serial(env)] + fn test_build_update() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + + let now = chrono::Utc::now().naive_utc(); + let conn = DiscordConnectionBuilder::new() + .access_token(constants::test::ACCESS_TOKEN.to_string()) + .expires_at(now) + .refresh_token(constants::test::REFRESH_TOKEN.to_string()) + .scope(constants::test::SCOPE.to_string()) + .build_update() + .unwrap(); + + // The access token is encrypted, so we can't compare it directly + assert_ne!(conn.access_token.unwrap(), constants::test::ACCESS_TOKEN); + assert!(conn.access_token_nonce.is_some()); + // The refresh token is encrypted, so we can't compare it directly + assert_ne!(conn.refresh_token.unwrap(), constants::test::REFRESH_TOKEN); + assert!(conn.refresh_token_nonce.is_some()); + assert_eq!(conn.expires_at.unwrap(), now); + assert_eq!(conn.scope.unwrap(), constants::test::SCOPE); + + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } +} diff --git a/src/database/wrappers/discord_connections/schema.rs b/src/database/wrappers/discord_connections/schema.rs new file mode 100644 index 0000000..6dbda21 --- /dev/null +++ b/src/database/wrappers/discord_connections/schema.rs @@ -0,0 +1,22 @@ +diesel::table! { + /// Represents an authorized Discord connection. + discord_connections (uid) { + /// The unique identifier for the connection. + /// This is the same as the user's Discord ID. + /// + /// Primary key. + uid -> Text, + /// The user's access token. + access_token -> Text, + /// The nonce that was used to encrypt the access token. + access_token_nonce -> Text, + /// The user's refresh token. + refresh_token -> Text, + /// The nonce that was used to encrypt the refresh token. + refresh_token_nonce -> Text, + /// The time at which the access token expires. + expires_at -> Timestamp, + /// The scopes granted by the user. + scope -> Text, + } +} diff --git a/src/database/wrappers/mod.rs b/src/database/wrappers/mod.rs new file mode 100644 index 0000000..29c3671 --- /dev/null +++ b/src/database/wrappers/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod account_links; +pub(crate) mod discord_connections; +pub(crate) mod roblox_connections; +pub(crate) mod sessions; + +/// The error type for operations on the database. +type Error = diesel::result::Error; diff --git a/src/database/wrappers/roblox_connections/mod.rs b/src/database/wrappers/roblox_connections/mod.rs new file mode 100644 index 0000000..7dc6bcb --- /dev/null +++ b/src/database/wrappers/roblox_connections/mod.rs @@ -0,0 +1,120 @@ +pub(crate) mod models; +mod schema; + +use self::models::{NewRobloxConnection, RobloxConnection}; +use crate::database::wrappers::roblox_connections::models::UpdateRobloxConnection; +use diesel::prelude::*; + +/// A collection of methods for interacting with the `roblox_connections` table. +pub(crate) struct RobloxConnectionsDb; + +impl RobloxConnectionsDb { + /// Inserts a new roblox connection into the database. + /// + /// # Arguments + /// + /// * `new_conn` - The new roblox connection to insert. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The newly inserted [`RobloxConnection`], if successful, or an error if the operation failed. + pub(crate) fn insert_one( + new_conn: NewRobloxConnection, + conn: &mut PgConnection, + ) -> Result { + use self::schema::roblox_connections; + + // Already exists + diesel::insert_into(roblox_connections::table) + .values(new_conn) + .returning(RobloxConnection::as_returning()) + .get_result(conn) + } + + /// Upserts (inserts or updates) a roblox connection into the database. + /// + /// # Arguments + /// + /// * `new_conn` - The new roblox connection to insert. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The newly inserted (or updated) [`RobloxConnection`], if successful, or an error if the operation failed. + pub(crate) fn upsert_one( + new_conn: NewRobloxConnection, + conn: &mut PgConnection, + ) -> Result { + // Update if already exists + if Self::find_one(&new_conn.uid, conn).is_some() { + let uid = new_conn.uid.clone(); + let update_conn = UpdateRobloxConnection::from(new_conn); + Self::update_one(&uid, update_conn, conn) + } else { + Self::insert_one(new_conn, conn) + } + } + + /// Finds a roblox connection in the database by its primary key. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the roblox connection to find. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// The [`RobloxConnection`], if found, or [`None`] if no connection with the primary key exists. + pub(crate) fn find_one(pk_uid: &str, conn: &mut PgConnection) -> Option { + use schema::roblox_connections::dsl::*; + + roblox_connections + .filter(uid.eq(pk_uid)) + .first::(conn) + .ok() + } + + /// Deletes a roblox connection from the database by its primary key. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the roblox connection to delete. + /// * `conn` - The database connection to use. + /// + /// # Returns + /// + /// `true` if the operation was successful, `false` otherwise. + pub(crate) fn delete_one(pk_uid: &str, conn: &mut PgConnection) -> bool { + use schema::roblox_connections::dsl::*; + + diesel::delete(roblox_connections) + .filter(uid.eq(pk_uid)) + .execute(conn) + .is_ok() + } + + /// Updates a roblox connection in the database. + /// + /// # Arguments + /// + /// * `pk_uid` - The primary key of the roblox connection to update. + /// * `new_conn` - The new roblox connection to update. + /// + /// # Returns + /// + /// The updated [`RobloxConnection`], if successful, or an error if the operation failed. + pub(crate) fn update_one( + pk_uid: &str, + new_conn: UpdateRobloxConnection, + conn: &mut PgConnection, + ) -> Result { + use schema::roblox_connections::dsl::*; + + diesel::update(roblox_connections) + .filter(uid.eq(pk_uid)) + .set(new_conn) + .returning(RobloxConnection::as_returning()) + .get_result(conn) + } +} diff --git a/src/database/wrappers/roblox_connections/models.rs b/src/database/wrappers/roblox_connections/models.rs new file mode 100644 index 0000000..2eb93a7 --- /dev/null +++ b/src/database/wrappers/roblox_connections/models.rs @@ -0,0 +1,243 @@ +use super::schema; +use diesel::prelude::*; + +/// Represents an authorized Roblox connection in the database. +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = schema::roblox_connections)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct RobloxConnection { + /// The unique identifier for the connection. + /// This is the same as the user's Roblox ID. + /// + /// Primary key. + pub(crate) uid: String, + /// The user's access token. + pub(crate) access_token: String, + /// The nonce that was used to encrypt the access token. + pub(crate) access_token_nonce: String, + /// The user's refresh token. + pub(crate) refresh_token: String, + /// The nonce that was used to encrypt the refresh token. + pub(crate) refresh_token_nonce: String, + /// The time at which the access token expires. + pub(crate) expires_at: chrono::NaiveDateTime, + /// The scopes granted by the user. + pub(crate) scope: String, +} + +/// Represents a new Roblox connection to be inserted into the database. +/// See [`RobloxConnection`] for field definitions. +#[derive(Insertable, Debug)] +#[diesel(table_name = schema::roblox_connections)] +pub(crate) struct NewRobloxConnection { + pub(crate) uid: String, + pub(crate) access_token: String, + access_token_nonce: String, + pub(crate) refresh_token: String, + refresh_token_nonce: String, + pub(crate) expires_at: chrono::NaiveDateTime, + pub(crate) scope: String, +} + +/// Represents an update to a Roblox connection in the database. +/// See [`RobloxConnection`] for field definitions. +#[derive(AsChangeset, Debug)] +#[diesel(table_name = schema::roblox_connections)] +pub(crate) struct UpdateRobloxConnection { + pub(crate) access_token: Option, + access_token_nonce: Option, + pub(crate) refresh_token: Option, + refresh_token_nonce: Option, + pub(crate) expires_at: Option, + pub(crate) scope: Option, +} + +/// A builder for creating new or updating existing Roblox connections. +/// See [`RobloxConnection`] for field definitions. +#[derive(Default)] +pub(crate) struct RobloxConnectionBuilder { + uid: Option, + access_token: Option, + access_token_nonce: Option, + refresh_token: Option, + refresh_token_nonce: Option, + expires_at: Option, + scope: Option, +} + +impl NewRobloxConnection { + /// Creates a new [`RobloxConnectionBuilder`] instance. + pub(crate) fn build() -> RobloxConnectionBuilder { + RobloxConnectionBuilder::default() + } +} + +impl RobloxConnectionBuilder { + /// Creates a new [`RobloxConnectionBuilder`] instance. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Sets the unique identifier for the connection. + /// This is the same as the user's Roblox ID. + pub(crate) fn uid(mut self, uid: String) -> Self { + self.uid = Some(uid); + self + } + + /// Sets the user's access token. + pub(crate) fn access_token(mut self, access_token: String) -> Self { + self.access_token = Some(access_token); + self + } + + /// Sets the time at which the access token expires. + pub(crate) fn expires_at(mut self, expires_at: chrono::NaiveDateTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Sets the user's refresh token. + pub(crate) fn refresh_token(mut self, refresh_token: String) -> Self { + self.refresh_token = Some(refresh_token); + self + } + + /// Sets the scopes granted by the user. + pub(crate) fn scope(mut self, scope: String) -> Self { + self.scope = Some(scope); + self + } + + /// Encrypts the access and refresh tokens. + fn encrypt_tokens(&mut self) -> Result<(), String> { + // Encrypt the access token if it exists + if let Some(access_token) = &self.access_token { + let data = crate::cipher::encrypt(access_token.as_bytes())?; + + self.access_token_nonce = Some(data.nonce); + self.access_token = Some(data.data); + } + + // Encrypt the refresh token if it exists + if let Some(refresh_token) = &self.refresh_token { + let data = crate::cipher::encrypt(refresh_token.as_bytes())?; + + self.refresh_token_nonce = Some(data.nonce); + self.refresh_token = Some(data.data); + } + + Ok(()) + } + + /// Builds the [`NewRobloxConnection`] instance. + pub(crate) fn build(mut self) -> Result { + self.encrypt_tokens()?; + + Ok(NewRobloxConnection { + uid: self.uid.expect("uid is required"), + access_token: self.access_token.expect("access_token is required"), + access_token_nonce: self + .access_token_nonce + .expect("access_token_nonce is required"), + expires_at: self.expires_at.expect("expires_at is required"), + refresh_token: self.refresh_token.expect("refresh_token is required"), + refresh_token_nonce: self + .refresh_token_nonce + .expect("refresh_token_nonce is required"), + scope: self.scope.expect("scope is required"), + }) + } + + /// Builds the [`UpdateRobloxConnection`] instance. + pub(crate) fn build_update(mut self) -> Result { + self.encrypt_tokens()?; + + Ok(UpdateRobloxConnection { + access_token: self.access_token, + access_token_nonce: self.access_token_nonce, + expires_at: self.expires_at, + refresh_token: self.refresh_token, + refresh_token_nonce: self.refresh_token_nonce, + scope: self.scope, + }) + } +} + +impl From for UpdateRobloxConnection { + fn from(new_conn: NewRobloxConnection) -> Self { + UpdateRobloxConnection { + access_token: Some(new_conn.access_token), + access_token_nonce: Some(new_conn.access_token_nonce), + expires_at: Some(new_conn.expires_at), + refresh_token: Some(new_conn.refresh_token), + refresh_token_nonce: Some(new_conn.refresh_token_nonce), + scope: Some(new_conn.scope), + } + } +} + +#[cfg(test)] +mod tests { + use super::RobloxConnectionBuilder; + use crate::constants; + use serial_test::file_serial; + + #[test] + #[file_serial(env)] + fn test_build() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + + let now = chrono::Utc::now().naive_utc(); + let conn = RobloxConnectionBuilder::new() + .uid(constants::test::UID.to_string()) + .access_token(constants::test::ACCESS_TOKEN.to_string()) + .expires_at(now) + .refresh_token(constants::test::REFRESH_TOKEN.to_string()) + .scope(constants::test::SCOPE.to_string()) + .build() + .unwrap(); + + // The access token is encrypted, so we can't compare it directly + assert_ne!(conn.access_token, constants::test::ACCESS_TOKEN); + // The refresh token is encrypted, so we can't compare it directly + assert_ne!(conn.refresh_token, constants::test::REFRESH_TOKEN); + assert_eq!(conn.uid, constants::test::UID); + assert_eq!(conn.expires_at, now); + assert_eq!(conn.scope, constants::test::SCOPE); + + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } + + #[test] + #[file_serial(env)] + fn test_build_update() { + std::env::set_var( + constants::env::ENCRYPTION_KEY, + constants::test::ENCRYPTION_KEY, + ); + + let now = chrono::Utc::now().naive_utc(); + let conn = RobloxConnectionBuilder::new() + .access_token(constants::test::ACCESS_TOKEN.to_string()) + .expires_at(now) + .refresh_token(constants::test::REFRESH_TOKEN.to_string()) + .scope(constants::test::SCOPE.to_string()) + .build_update() + .unwrap(); + + // The access token is encrypted, so we can't compare it directly + assert_ne!(conn.access_token.unwrap(), constants::test::ACCESS_TOKEN); + assert!(conn.access_token_nonce.is_some()); + // The refresh token is encrypted, so we can't compare it directly + assert_ne!(conn.refresh_token.unwrap(), constants::test::REFRESH_TOKEN); + assert!(conn.refresh_token_nonce.is_some()); + assert_eq!(conn.expires_at.unwrap(), now); + assert_eq!(conn.scope.unwrap(), constants::test::SCOPE); + + std::env::remove_var(constants::env::ENCRYPTION_KEY); + } +} diff --git a/src/database/wrappers/roblox_connections/schema.rs b/src/database/wrappers/roblox_connections/schema.rs new file mode 100644 index 0000000..36d5d51 --- /dev/null +++ b/src/database/wrappers/roblox_connections/schema.rs @@ -0,0 +1,21 @@ +diesel::table! { + roblox_connections (uid) { + /// The unique identifier for the connection. + /// This is the same as the user's Roblox ID. + /// + /// Primary key. + uid -> Text, + /// The user's access token. + access_token -> Text, + /// The nonce that was used to encrypt the access token. + access_token_nonce -> Text, + /// The user's refresh token. + refresh_token -> Text, + /// The nonce that was used to encrypt the refresh token. + refresh_token_nonce -> Text, + /// The time at which the access token expires. + expires_at -> Timestamp, + /// The scopes granted by the user. + scope -> Text, + } +} diff --git a/src/database/wrappers/sessions/mod.rs b/src/database/wrappers/sessions/mod.rs new file mode 100644 index 0000000..d8fa241 --- /dev/null +++ b/src/database/wrappers/sessions/mod.rs @@ -0,0 +1,158 @@ +pub(crate) mod models; +mod schema; + +use self::models::{NewSession, Session}; +use crate::database::wrappers::sessions::models::UpdateSession; +use crate::response::ApiError; +use crate::{constants, DbConn}; +use diesel::prelude::*; +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; + +/// A collection of methods for interacting with the `sessions` table. +pub(crate) struct SessionsDb; + +impl SessionsDb { + /// Inserts a new session into the database. + /// + /// # Parameters + /// + /// - `new_session` - The new session to insert. + /// - `conn` - The database connection to use. + /// + /// # Returns + /// + /// The newly inserted [`Session`], if successful, [`None`] if the session already exists, + /// or an error if the operation failed. + pub(crate) fn insert_one( + new_session: NewSession, + conn: &mut PgConnection, + ) -> Result, super::Error> { + use self::schema::sessions; + + // Already exists + if Self::find_one(&new_session.session_id, conn).is_some() { + return Ok(None); + } + + diesel::insert_into(sessions::table) + .values(new_session) + .returning(Session::as_returning()) + .get_result(conn) + .map(Some) + } + + /// Finds a session in the database by its primary key. + /// + /// # Parameters + /// + /// - `pk_session_id` - The primary key of the session to find. + /// - `conn` - The database connection to use. + /// + /// # Returns + /// + /// The [`Session`], if found, or [`None`] if no session with the primary key exists. + pub(crate) fn find_one(pk_session_id: &str, conn: &mut PgConnection) -> Option { + use self::schema::sessions::dsl::{session_id, sessions}; + + sessions + .filter(session_id.eq(pk_session_id)) + .first::(conn) + .ok() + } + + /// Deletes a session from the database by its primary key. + /// + /// # Parameters + /// + /// - `pk_session_id` - The primary key of the session to delete. + /// - `conn` - The database connection to use. + /// + /// # Returns + /// + /// `true` if the session was successfully deleted, `false` if the session did not exist. + pub(crate) fn delete_one(pk_session_id: &str, conn: &mut PgConnection) -> bool { + use self::schema::sessions::dsl::{session_id, sessions}; + + diesel::delete(sessions.filter(session_id.eq(pk_session_id))) + .execute(conn) + .is_ok() + } + + /// Updates a session in the database by its primary key. + /// + /// # Parameters + /// + /// - `pk_session_id` - The primary key of the session to update. + /// - `updated_session` - The updated session data. + /// - `conn` - The database connection to use. + /// + /// # Returns + /// + /// The updated session, if successful, or [`None`] if the session did not exist. + pub(crate) fn update_one( + pk_session_id: &str, + updated_session: UpdateSession, + conn: &mut PgConnection, + ) -> Result { + use self::schema::sessions::dsl::{session_id, sessions}; + + diesel::update(sessions) + .filter(session_id.eq(pk_session_id)) + .set(updated_session) + .returning(Session::as_returning()) + .get_result(conn) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Session { + type Error = ApiError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + // Extract the session ID from the session cookie. + let session_id = request + .cookies() + .get_pending(constants::cookie::SESSION_ID) + .map(|cookie| cookie.value().to_string()); + + // Ensure the session ID cookie exists. + let Some(session_id) = session_id else { + return Outcome::Error(( + Status::Unauthorized, + ApiError::message( + Status::Unauthorized, + format!("Missing {} cookie", constants::cookie::SESSION_ID), + ), + )); + }; + + // Get the database connection from the request guard. + let conn = request.guard::().await; + // Ensure the database connection was successfully retrieved. + let Outcome::Success(conn) = conn else { + return Outcome::Error(( + Status::InternalServerError, + ApiError::message( + Status::InternalServerError, + "Failed to access database request guard", + ), + )); + }; + + // Find the session in the database. + let session = conn + .run(move |conn| SessionsDb::find_one(&session_id, conn)) + .await; + + // Ensure the session was successfully retrieved. + match session { + Some(session) => Outcome::Success(session), + None => Outcome::Error(( + Status::Unauthorized, + ApiError::message(Status::Unauthorized, "Invalid session ID"), + )), + } + } +} diff --git a/src/database/wrappers/sessions/models.rs b/src/database/wrappers/sessions/models.rs new file mode 100644 index 0000000..854d98b --- /dev/null +++ b/src/database/wrappers/sessions/models.rs @@ -0,0 +1,112 @@ +use super::schema; +use diesel::prelude::*; + +/// Represents a session in the database. +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = schema::sessions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct Session { + /// The unique identifier for the session. + /// + /// Primary key. + pub(crate) session_id: String, + /// The Discord user ID associated with the session. + /// + /// Foreign key to the `discord_connections` table. + pub(crate) discord_uid: String, + /// The expiration time of the session. + pub(crate) expires_at: chrono::NaiveDateTime, +} + +/// Represents a new session to be inserted into the database. +/// See [`Session`] for field definitions. +#[derive(Insertable, Debug)] +#[diesel(table_name = schema::sessions)] +pub(crate) struct NewSession { + pub(crate) session_id: String, + pub(crate) discord_uid: String, + pub(crate) expires_at: chrono::NaiveDateTime, +} + +/// Represents an update to an existing session in the database. +/// See [`Session`] for field definitions. +#[derive(AsChangeset, Debug)] +#[diesel(table_name = schema::sessions)] +pub(crate) struct UpdateSession { + pub(crate) session_id: Option, + pub(crate) discord_uid: Option, + pub(crate) expires_at: Option, +} + +/// A builder for creating new or updating existing session records. +/// See [`Session`] for field definitions. +/// +/// This struct provides a builder pattern for constructing [`NewSession`] and [`UpdateSession`] instances. +#[derive(Debug, Default)] +pub(crate) struct SessionBuilder { + session_id: Option, + discord_uid: Option, + expires_at: Option, +} + +impl NewSession { + /// Creates a new [`SessionBuilder`] instance. + pub(crate) fn build() -> SessionBuilder { + SessionBuilder::default() + } +} + +impl UpdateSession { + /// Creates a new [`SessionBuilder`] instance. + pub(crate) fn build() -> SessionBuilder { + SessionBuilder::default() + } +} + +impl SessionBuilder { + /// Creates a new [`SessionBuilder`] instance. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Sets the Discord user ID for the session. + pub(crate) fn discord_uid(mut self, discord_uid: String) -> Self { + self.discord_uid = Some(discord_uid); + self + } + + /// Sets the session ID for the session. + pub(crate) fn session_id(mut self, session_id: String) -> Self { + self.session_id = Some(session_id); + self + } + + /// Sets the expiration time for the session. + pub(crate) fn expires_at(mut self, expires_at: chrono::NaiveDateTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Builds the [`NewSession`] instance. + /// + /// # Panics + /// + /// Panics if any of the required fields are not set. + /// See [`NewSession`] for more information. + pub(crate) fn build(self) -> NewSession { + NewSession { + discord_uid: self.discord_uid.expect("discord_uid not set"), + session_id: self.session_id.expect("session_id not set"), + expires_at: self.expires_at.expect("expires_at not set"), + } + } + + /// Builds the [`UpdateSession`] instance. + pub(crate) fn build_update(self) -> UpdateSession { + UpdateSession { + discord_uid: self.discord_uid, + session_id: self.session_id, + expires_at: self.expires_at, + } + } +} diff --git a/src/database/wrappers/sessions/schema.rs b/src/database/wrappers/sessions/schema.rs new file mode 100644 index 0000000..b463e55 --- /dev/null +++ b/src/database/wrappers/sessions/schema.rs @@ -0,0 +1,15 @@ +diesel::table! { + /// Represents a session in the database. + sessions (session_id) { + /// The unique identifier for the session. + /// + /// Primary key. + session_id -> Text, + /// The Discord user ID associated with the session. + /// + /// Foreign key to the `discord_connections` table. + discord_uid -> Text, + /// The expiration time of the session. + expires_at -> Timestamp, + } +} diff --git a/src/lib.rs b/src/lib.rs index c134a0f..732b455 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,38 +1,68 @@ -#[macro_use] -pub extern crate rocket; +#![allow(dead_code)] -use dotenvy::dotenv; -use rocket::serde::json::{json, Value}; +mod cipher; +pub mod config; +mod constants; +mod database; +mod oauth; +mod response; +mod utils; + +#[macro_use] +pub(crate) extern crate rocket; +use rocket::http::Status; +use rocket::Request; #[macro_use] extern crate rocket_sync_db_pools; -extern crate rocket_cors; -use rocket_cors::{Cors, CorsOptions}; +use crate::config::Config; +use crate::response::ApiError; +use crate::utils::construct_api_route; +use dotenvy::dotenv; +/// Represents a connection to the PostgreSQL database. +/// +/// This struct is used to manage a connection to the PostgreSQL database +/// using Diesel's `PgConnection`. #[database("roops")] -pub struct DbConn(diesel::PgConnection); - -fn cors_fairing() -> Cors { - CorsOptions::default() - .to_cors() - .expect("Cors fairing cannot be created") -} +pub(crate) struct DbConn(diesel::PgConnection); -#[catch(404)] -fn not_found() -> Value { - json!({ - "status": 404, - "message": "Not Found" - }) +/// Default error catcher for the Rocket application. +/// +/// This function handles all uncaught errors and converts them into an [`ApiError`] +/// with the appropriate status code. +/// +/// # Arguments +/// +/// * `status` - The HTTP status code of the error. +/// * `_req` - The request that caused the error. +/// +/// # Returns +/// +/// An [`ApiError`] instance with the appropriate status code. +#[catch(default)] +fn default(status: Status, _req: &Request<'_>) -> ApiError { + ApiError::status(status) } +/// Launches the Rocket application. +/// +/// This function initializes the Rocket application, loads the configuration, +/// attaches the CORS fairing and database connection, mounts the OAuth2 routes, +/// and registers the default error catcher. +/// +/// # Returns +/// +/// A [`Rocket`](rocket::Rocket) instance in the build phase, configured with the application's settings. #[launch] pub fn rocket() -> _ { dotenv().ok(); + let cfg = Config::load(None).unwrap(); rocket::build() - .attach(cors_fairing()) + .attach(cfg.cors.clone()) .attach(DbConn::fairing()) - // .mount("/v1", []) - .register("/", catchers![not_found]) + .mount(construct_api_route("/oauth2"), oauth::routes::routes()) + .register("/", catchers![default]) + .manage(cfg) } diff --git a/src/main.rs b/src/main.rs index 0a1efe3..d612e60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,19 @@ #[rocket::main] async fn main() -> Result<(), rocket::Error> { - roops_internal_api::rocket().launch().await?; + // Start the Rocket server if no arguments are provided + // Otherwise, validate the config if the validate-config argument is provided with an optional path + let Some(cmd) = std::env::args().nth(1) else { + roops_internal_api::rocket().launch().await?; + return Ok(()); + }; + + match cmd.as_str() { + "validate-config" => { + // Validate the config + let path = std::env::args().nth(2); + roops_internal_api::config::Config::load(path).unwrap(); + } + _ => panic!("Invalid command: {}", cmd), + } Ok(()) } diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs new file mode 100644 index 0000000..36ebc63 --- /dev/null +++ b/src/oauth/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod routes; +mod types; +mod utils; diff --git a/src/oauth/routes/discord.rs b/src/oauth/routes/discord.rs new file mode 100644 index 0000000..55efeca --- /dev/null +++ b/src/oauth/routes/discord.rs @@ -0,0 +1,231 @@ +use crate::cipher::{decrypt, EncryptedData}; +use crate::config::Config; +use crate::constants; +use crate::database::wrappers::discord_connections::models::{ + NewDiscordConnection, UpdateDiscordConnection, +}; +use crate::database::wrappers::discord_connections::DiscordConnectionsDb; +use crate::database::wrappers::sessions::models::{NewSession, Session}; +use crate::database::wrappers::sessions::SessionsDb; +use crate::oauth::types::discord::{DiscordOAuthScopeSet, DiscordOAuthScopes}; +use crate::oauth::types::OAuthCallback; +use crate::oauth::utils::discord::{ + construct_discord_oauth_url, exchange_code, get_authorized_user, refresh_token, +}; +use crate::oauth::utils::{generate_session, generate_state}; +use crate::response::{ApiError, ApiResponse, ApiResult}; +use crate::utils::construct_api_route; +use crate::DbConn; +use diesel::Connection; +use rocket::http::{Cookie, CookieJar, SameSite, Status}; +use rocket::response::Redirect; +use rocket::time::Duration; +use rocket::State; + +/// Initiates the Discord OAuth flow by saving a randomly-generated state in a cookie +/// and redirecting the user to the Discord OAuth page. +/// +/// # Possible Responses +/// +/// - `303 See Other` with a redirect to the Discord OAuth page. +/// - `400 Bad Request` if the scope set is invalid. +#[get("/discord/initiate?")] +pub(super) fn discord_oauth_initiate( + scope_set: &str, + jar: &CookieJar<'_>, + cfg: &State, +) -> ApiResult { + let scopes = DiscordOAuthScopeSet::try_from(scope_set) + .map_err(|e| ApiError::message(Status::BadRequest, e))?; + let scopes = DiscordOAuthScopes::from(scopes); + let state = generate_state(); + let redirect_uri = construct_discord_oauth_url(scopes, &state, cfg); + + // Build a state cookie that will be used to verify the callback + let auth_cookie = Cookie::build((constants::cookie::STATE, state)) + .path(construct_api_route("/oauth2/discord/callback")) + .same_site(SameSite::Lax) + .max_age(Duration::minutes(5)); + jar.add_private(auth_cookie); + + // Redirect the user to the Discord OAuth page + Ok(Redirect::to(redirect_uri)) +} + +/// Handles the Discord OAuth callback by verifying the state and exchanging the code for a token. +/// +/// - Verifies the state against the one saved in the cookie. +/// - Exchanges the code for a token. +/// - Fetches the authorized user. +/// - Inserts the session and Discord connection into the database. +/// - Saves the current session as a cookie. +/// +/// # Possible Responses +/// +/// - `303 See Other` with a redirect to the main page. +/// - `400 Bad Request` if the state is invalid. +/// - `500 Internal Server Error` +/// - If the code exchange response cannot be parsed +/// - If the authenticated user is missing the 'identify' scope. +/// - If the access token or refresh token cannot be encrypted. +/// - If the session cannot be inserted or the Discord connection cannot be upserted into the database. +/// - `502 Bad Gateway` if the code exchange fails. +#[get("/discord/callback?")] +pub(super) async fn discord_oauth_callback( + callback: OAuthCallback, + jar: &CookieJar<'_>, + conn: DbConn, + cfg: &State, +) -> ApiResult { + // Verify the state against the one that was saved in the cookie + let is_valid_state = jar + .get_pending(constants::cookie::STATE) + .map_or(false, |cookie| cookie.value() == callback.state); + + if !is_valid_state { + return Err(ApiError::message(Status::BadRequest, "Invalid state")); + } + + // Use the code to obtain the token + let response = exchange_code(&callback.code, cfg)?; + // Fetch the authorized user + let authorized_user = get_authorized_user(&response.access_token)?; + // Parse the user info to get the Discord UID + let discord_uid = authorized_user + .ok_or(ApiError::message( + Status::InternalServerError, + "Failed to unwrap user, missing 'identify' scope", + ))? + .id; + + let session = generate_session(); + let token_expires_at = + chrono::Utc::now().naive_utc() + chrono::Duration::seconds(response.expires_in); + + // Session to insert into the database + let new_session = NewSession::build() + .discord_uid(discord_uid.clone()) + .session_id(session.session_id.clone()) + .expires_at(token_expires_at) + .build(); + + // Discord connection to insert into the database + let discord_connection = NewDiscordConnection::build() + .uid(discord_uid) + .access_token(response.access_token) + .expires_at(token_expires_at) + .refresh_token(response.refresh_token) + .scope(response.scope) + .build() + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to encrypt access token and/or refresh token", + ) + })?; + + // Insert the session and Discord connection into the database + conn.run(|conn| { + conn.transaction(|conn| { + DiscordConnectionsDb::upsert_one(discord_connection, conn)?; + SessionsDb::insert_one(new_session, conn)?; + diesel::result::QueryResult::Ok(()) + }) + }) + .await + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to insert session or upsert Discord connection into database", + ) + })?; + + // Save the current session as a cookie + // This will be used to authenticate the user in future requests + jar.add_private(session.cookie); + + // Redirect back to the main page + Ok(Redirect::to(uri!("/connections/discord/success"))) +} + +/// Refreshes the Discord token by decrypting the refresh token, refreshing the token, and updating the database. +/// +/// # Possible Responses +/// +/// - `204 No Content` if the token was successfully refreshed. +/// - `401 Unauthorized` +/// - If the session cookie is missing. +/// - If the session is not found in the database. +/// - `500 Internal Server Error` +/// - If the Discord connection associated with the session is not found. +/// - If the refresh token cannot be decrypted. +/// - If the token refresh response cannot be parsed. +/// - If the access token or refresh token cannot be encrypted. +/// - If the Discord connection cannot be updated in the database. +/// - `502 Bad Gateway` if the token refresh fails. +#[post("/discord/refresh-token")] +pub(super) async fn discord_refresh_token( + conn: DbConn, + session: Session, + cfg: &State, +) -> ApiResult { + // Fetch the Discord connection from the database + let discord_connection = conn + .run(move |conn| DiscordConnectionsDb::find_one(&session.discord_uid, conn)) + .await + .ok_or(ApiError::message( + Status::InternalServerError, + "Discord connection not found", + ))?; + + // Decrypt the refresh token + let discord_refresh_token = decrypt(&EncryptedData { + data: discord_connection.refresh_token, + nonce: discord_connection.refresh_token_nonce, + }) + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to decrypt refresh token", + ) + })?; + + // Refresh the token + let response = refresh_token(&discord_refresh_token, cfg)?; + let token_expires_at = + chrono::Utc::now().naive_utc() + chrono::Duration::seconds(response.expires_in); + + let new_discord_connection = UpdateDiscordConnection::build() + .access_token(response.access_token) + .refresh_token(response.refresh_token) + .expires_at(token_expires_at) + .build_update() + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to encrypt access token and/or refresh token", + ) + })?; + + // Update the Discord connection in the database + let success = conn + .run(move |conn| { + DiscordConnectionsDb::update_one( + &discord_connection.uid, + new_discord_connection, + conn, + )?; + diesel::result::QueryResult::Ok(()) + }) + .await + .is_ok(); + + if success { + Ok(ApiResponse::status(Status::NoContent)) + } else { + Err(ApiError::message( + Status::InternalServerError, + "Failed to update Discord connection", + )) + } +} diff --git a/src/oauth/routes/mod.rs b/src/oauth/routes/mod.rs new file mode 100644 index 0000000..f7fbb5c --- /dev/null +++ b/src/oauth/routes/mod.rs @@ -0,0 +1,53 @@ +use crate::database::wrappers::sessions::models::{Session, UpdateSession}; +use crate::database::wrappers::sessions::SessionsDb; +use crate::oauth::utils::generate_session; +use crate::response::{ApiError, ApiResponse, ApiResult}; +use crate::DbConn; +use rocket::http::{CookieJar, Status}; + +mod discord; +mod roblox; + +/// Refreshes the session ID by updating the expiration date +/// +/// # Possible Responses +/// +/// - `204 No Content` if the session was successfully refreshed. +/// - `401 Unauthorized` +/// - If the session cookie is missing. +/// - If the session is not found in the database. +/// - `500 Internal Server Error` if the session cannot be updated in the database. +#[post("/refresh-session")] +async fn refresh_session(jar: &CookieJar<'_>, conn: DbConn, session: Session) -> ApiResult { + // Generate a new session + let new_session = generate_session(); + let new_session_cookie = new_session.cookie; + let new_session = UpdateSession::build() + .session_id(new_session.session_id.clone()) + .expires_at(new_session.expires_at) + .build_update(); + + // Update the session in the database + conn.run(move |conn| SessionsDb::update_one(&session.session_id, new_session, conn)) + .await + .map_err(|_| ApiError::message(Status::InternalServerError, "Failed to update session"))?; + + // Update the session cookie + jar.add_private(new_session_cookie); + + Ok(ApiResponse::status(Status::NoContent)) +} + +/// Returns the routes for the OAuth module. +/// +/// These routes should be mounted on `/oauth2`. +pub(crate) fn routes() -> Vec { + routes![ + discord::discord_oauth_initiate, + discord::discord_oauth_callback, + discord::discord_refresh_token, + roblox::roblox_oauth_initiate, + roblox::roblox_oauth_callback, + refresh_session, + ] +} diff --git a/src/oauth/routes/roblox.rs b/src/oauth/routes/roblox.rs new file mode 100644 index 0000000..5fb9a27 --- /dev/null +++ b/src/oauth/routes/roblox.rs @@ -0,0 +1,146 @@ +use crate::config::Config; +use crate::constants; +use crate::database::wrappers::account_links::models::NewAccountLink; +use crate::database::wrappers::account_links::AccountLinksDb; +use crate::database::wrappers::roblox_connections::models::NewRobloxConnection; +use crate::database::wrappers::roblox_connections::RobloxConnectionsDb; +use crate::database::wrappers::sessions::models::Session; +use crate::oauth::types::roblox::{RobloxOAuthScopeSet, RobloxOAuthScopes}; +use crate::oauth::types::OAuthCallback; +use crate::oauth::utils::generate_state; +use crate::oauth::utils::pixy::Pixy; +use crate::oauth::utils::roblox::{construct_roblox_oauth_url, exchange_code, get_authorized_user}; +use crate::response::{ApiError, ApiResult}; +use crate::utils::construct_api_route; +use crate::DbConn; +use diesel::Connection; +use rocket::http::{Cookie, CookieJar, SameSite, Status}; +use rocket::response::Redirect; +use rocket::time::Duration; +use rocket::State; + +/// Handles the Roblox OAuth callback by verifying the state and code verifier, +/// and exchanging the code for a token. +/// +/// - Verifies the state against the one saved in the cookie. +/// - Verifies the code verifier against the one saved in the cookie. +/// - Exchanges the code for a token. +/// - Fetches the authorized user. +/// - Inserts the account link and Roblox connection into the database. +/// +/// # Possible Responses +/// +/// - `303 See Other` with a redirect to the success page. +/// - `400 Bad Request` if the state is invalid. +/// - `401 Unauthorized` +/// - If the session cookie is missing. +/// - If the session is not found in the database. +/// - `500 Internal Server Error` +/// - If the code exchange response cannot be parsed +/// - If the authenticated user is missing the 'identify' scope. +/// - If the access token or refresh token cannot be encrypted. +/// - If the account link or Roblox connection cannot be upserted into the database. +/// - `502 Bad Gateway` if the code exchange fails. +#[get("/roblox/callback?")] +pub(super) async fn roblox_oauth_callback( + callback: OAuthCallback, + jar: &CookieJar<'_>, + conn: DbConn, + session: Session, + cfg: &State, +) -> ApiResult { + // Verify the state against the one that was saved in the cookie + let is_valid_state = jar + .get_pending(constants::cookie::STATE) + .map_or(false, |cookie| cookie.value() == callback.state); + + if !is_valid_state { + return Err(ApiError::message(Status::BadRequest, "Invalid state")); + } + + let verifier_cookie = jar + .get_pending(constants::cookie::OAUTH_CODE_VERIFIER) + .ok_or_else(|| ApiError::message(Status::BadRequest, "Missing code verifier cookie"))?; + + let response = exchange_code(&callback.code, verifier_cookie.value(), cfg)?; + let roblox_uid = get_authorized_user(&response.access_token)?.id; + + let token_expires_at = + chrono::Utc::now().naive_utc() + chrono::Duration::seconds(response.expires_in); + + let roblox_connection = NewRobloxConnection::build() + .uid(roblox_uid.clone()) + .access_token(response.access_token) + .expires_at(token_expires_at) + .refresh_token(response.refresh_token) + .scope(response.scope) + .build() + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to encrypt access and/or refresh token", + ) + })?; + + let account_link = NewAccountLink::build() + .discord_uid(session.discord_uid) + .roblox_uid(roblox_uid.clone()) + .is_primary(true) + .build(); + + conn.run(|conn| { + conn.transaction(|conn| { + RobloxConnectionsDb::upsert_one(roblox_connection, conn)?; + AccountLinksDb::upsert_one(account_link, conn)?; + diesel::result::QueryResult::Ok(()) + }) + }) + .await + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to upsert account link or roblox connection into database", + ) + })?; + + Ok(Redirect::to(uri!("/connections/roblox/success"))) +} + +/// Initiates the Roblox OAuth flow by saving a randomly-generated state and code verifier in a cookie, +/// and redirecting the user to the Roblox OAuth page. +/// +/// # Possible Responses +/// +/// - `303 See Other` with a redirect to the Roblox OAuth page. +/// - `400 Bad Request` if the scope set is invalid. +/// - `401 Unauthorized` +/// - If the session cookie is missing. +/// - If the session is not found in the database. +#[get("/roblox/initiate?")] +pub(super) async fn roblox_oauth_initiate( + scope_set: &str, + jar: &CookieJar<'_>, + _session: Session, + cfg: &State, +) -> ApiResult { + let scopes = RobloxOAuthScopeSet::try_from(scope_set) + .map_err(|e| ApiError::message(Status::BadRequest, e))?; + let scopes = RobloxOAuthScopes::from(scopes); + let state = generate_state(); + let pixy = Pixy::new(); + let redirect_uri = construct_roblox_oauth_url(&pixy.challenge, scopes, &state, cfg); + + let auth_cookie = Cookie::build((constants::cookie::STATE, state)) + .path(construct_api_route("/oauth2/roblox/callback")) + .same_site(SameSite::Lax) + .max_age(Duration::minutes(5)); + jar.add_private(auth_cookie); + + let verifier_cookie = Cookie::build((constants::cookie::OAUTH_CODE_VERIFIER, pixy.verifier)) + .path(construct_api_route("/oauth2/roblox/callback")) + .same_site(SameSite::Lax) + .max_age(Duration::minutes(5)); + jar.add_private(verifier_cookie); + + Ok(Redirect::to(redirect_uri)) +} diff --git a/src/oauth/types/discord.rs b/src/oauth/types/discord.rs new file mode 100644 index 0000000..408552d --- /dev/null +++ b/src/oauth/types/discord.rs @@ -0,0 +1,362 @@ +use crate::config::Config; +use crate::constants; +use crate::url; +use rocket::serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +/// A set of scopes that will be requested by the web app. +/// +/// The internal API should parse these scope sets into individual scopes +/// that will be requested from the OAuth2 provider +#[derive(PartialEq, Debug)] +pub(crate) enum DiscordOAuthScopeSet { + /// The scopes required for verifying a user's Discord account + Verification, +} + +/// A set of scopes that will be requested from the OAuth2 provider. +#[derive(PartialEq, Debug)] +pub(crate) struct DiscordOAuthScopes(pub(crate) Vec); + +/// A scope that can be requested from the OAuth2 provider. +/// +/// [Reference](https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes) +#[derive(PartialEq, Debug)] +pub(crate) enum DiscordOAuthScope { + Identify, + Guilds, + GuildsChannelsRead, + Rpc, + RPCVoiceWrite, + RPCScreenshareRead, + WebhookIncoming, + ApplicationsBuildsRead, + ApplicationsEntitlements, + RelationshipsRead, + DMChannelsRead, + PresencesWrite, + DMChannelsMessagesWrite, + PaymentSourcesCountryCode, + ApplicationsCommandsPermissionsUpdate, + Email, + GuildsJoin, + GDMJoin, + RPCNotificationsRead, + RPCVideoRead, + RPCScreenshareWrite, + MessagesRead, + ApplicationsCommands, + ActivitiesRead, + RelationshipsWrite, + RoleConnectionsWrite, + OpenID, + GatewayConnect, + SDKSocialLayer, + RPCActivitiesWrite, + ApplicationsBuildsUpload, + ApplicationsStoreUpdate, + ActivitiesWrite, + Voice, + PresencesRead, + DMChannelsMessagesRead, + AccountGlobalNameUpdate, + Connections, + GuildsMembersRead, + Bot, + RPCVoiceRead, + RPCVideoWrite, +} + +/// The request body for the Discord OAuth2 token endpoint. +/// +/// [Reference](https://discord.com/developers/docs/topics/oauth2#authorization-code-grant) +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct DiscordAuthorizationCodeRequestBody<'a> { + /// The client ID of the application + client_id: &'a str, + /// The client secret of the application. + /// Must be an owned string as it is retrieved from the environment + client_secret: String, + /// The grant type of the request. This field must contain the value `authorization_code` + grant_type: &'a str, + /// The authorization code that was received from the authorization callback + code: &'a str, + /// The callback URL that was used to request the authorization code + redirect_uri: &'a str, +} + +/// The request body for the Discord OAuth2 token refresh endpoint. +/// +/// [Reference](https://discord.com/developers/docs/topics/oauth2#authorization-code-grant) +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct DiscordRefreshTokenBody<'a> { + /// The grant type of the request. This field must contain the value `refresh_token` + grant_type: &'a str, + /// The refresh token that was received from the token endpoint + refresh_token: &'a str, + /// The client ID of the application + client_id: &'a str, + /// The client secret of the application + /// Must be an owned string as it is retrieved from the environment + client_secret: String, +} + +/// The response from the Discord OAuth2 token endpoint. +/// +/// [Reference](https://discord.com/developers/docs/topics/oauth2#authorization-code-grant-access-token-response) +#[derive(Deserialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct DiscordAccessTokenResponse { + /// The access token that can be used to authenticate requests + pub(crate) access_token: String, + /// The type of token + pub(crate) token_type: String, + /// The number of seconds until the token expires + pub(crate) expires_in: i64, + /// The refresh token that can be used to refresh the access token + pub(crate) refresh_token: String, + /// The scopes that the user has authorized + pub(crate) scope: String, +} + +/// The response from the Discord OAuth2 @me endpoint. +/// +/// [Reference](https://discord.com/developers/docs/topics/oauth2#get-current-authorization-information-example-authorization-information) +#[derive(Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub(crate) struct DiscordAuthorizedUserResponse { + /// The user who has authorized the application + /// + /// ⚠️ Requires the `identify` scope + pub(crate) user: Option, +} + +/// The user object represents a user profile on Discord. +/// +/// [Reference](https://discord.com/developers/docs/resources/user#user-object) +#[derive(Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub(crate) struct DiscordUser { + /// The user's ID + pub(crate) id: String, + /// The user's username, not unique across the platform + pub(crate) username: String, + /// The user's 4-digit discord-tag + pub(crate) discriminator: String, + /// The user's display name, if it is set. For bots, this is the application name + pub(crate) global_name: Option, + /// The user's [avatar hash](https://discord.com/developers/docs/reference#image-formatting) + pub(crate) avatar: Option, + /// Whether the user belongs to an OAuth2 application + pub(crate) bot: Option, + /// Whether the user is an Official Discord System user (part of the urgent message system) + pub(crate) system: Option, + /// Whether the user has two factor enabled on their account + pub(crate) mfa_enabled: Option, + /// The user's [banner hash](https://discord.com/developers/docs/reference#image-formatting) + pub(crate) banner: Option, + /// The user's banner color encoded as an integer representation of hexadecimal color code + pub(crate) accent_color: Option, + /// The user's chosen [language option](https://discord.com/developers/docs/reference#locales) + pub(crate) locale: Option, + /// Whether the email on this account has been verified + /// + /// ⚠️ Requires the `email` scope + pub(crate) verified: Option, + /// The user's email + /// + /// ⚠️ Requires the `email` scope + pub(crate) email: Option, + /// The [flags](https://discord.com/developers/docs/resources/user#user-object-user-flags) on a user's account + pub(crate) flags: Option, + /// The [type of Nitro subscription](https://discord.com/developers/docs/resources/user#user-object-premium-types) on a user's account + pub(crate) premium_type: Option, + /// The public [flags](https://discord.com/developers/docs/resources/user#user-object-user-flags) on a user's account + pub(crate) public_flags: Option, +} + +impl From<&DiscordOAuthScope> for &str { + fn from(value: &DiscordOAuthScope) -> Self { + match value { + DiscordOAuthScope::Identify => "identify", + DiscordOAuthScope::Guilds => "guilds", + DiscordOAuthScope::GuildsChannelsRead => "guilds.channels.read", + DiscordOAuthScope::Rpc => "rpc", + DiscordOAuthScope::RPCVoiceWrite => "rpc.voice.write", + DiscordOAuthScope::RPCScreenshareRead => "rpc.screenshare.read", + DiscordOAuthScope::WebhookIncoming => "webhook.incoming", + DiscordOAuthScope::ApplicationsBuildsRead => "applications.builds.read", + DiscordOAuthScope::ApplicationsEntitlements => "applications.entitlements", + DiscordOAuthScope::RelationshipsRead => "relationships.read", + DiscordOAuthScope::DMChannelsRead => "dm.channels.read", + DiscordOAuthScope::PresencesWrite => "presences.write", + DiscordOAuthScope::DMChannelsMessagesWrite => "dm.channels.messages.write", + DiscordOAuthScope::PaymentSourcesCountryCode => "payment.sources.country_code", + DiscordOAuthScope::ApplicationsCommandsPermissionsUpdate => { + "applications.commands.permissions.update" + } + DiscordOAuthScope::Email => "email", + DiscordOAuthScope::GuildsJoin => "guilds.join", + DiscordOAuthScope::GDMJoin => "gdm.join", + DiscordOAuthScope::RPCNotificationsRead => "rpc.notifications.read", + DiscordOAuthScope::RPCVideoRead => "rpc.video.read", + DiscordOAuthScope::RPCScreenshareWrite => "rpc.screenshare.write", + DiscordOAuthScope::MessagesRead => "messages.read", + DiscordOAuthScope::ApplicationsCommands => "applications.commands", + DiscordOAuthScope::ActivitiesRead => "activities.read", + DiscordOAuthScope::RelationshipsWrite => "relationships.write", + DiscordOAuthScope::RoleConnectionsWrite => "role.connections.write", + DiscordOAuthScope::OpenID => "openid", + DiscordOAuthScope::GatewayConnect => "gateway.connect", + DiscordOAuthScope::SDKSocialLayer => "sdk.social_layer", + DiscordOAuthScope::RPCActivitiesWrite => "rpc.activities.write", + DiscordOAuthScope::ApplicationsBuildsUpload => "applications.builds.upload", + DiscordOAuthScope::ApplicationsStoreUpdate => "applications.store.update", + DiscordOAuthScope::ActivitiesWrite => "activities.write", + DiscordOAuthScope::Voice => "voice", + DiscordOAuthScope::PresencesRead => "presences.read", + DiscordOAuthScope::DMChannelsMessagesRead => "dm.channels.messages.read", + DiscordOAuthScope::AccountGlobalNameUpdate => "account.global.name.update", + DiscordOAuthScope::Connections => "connections", + DiscordOAuthScope::GuildsMembersRead => "guilds.members.read", + DiscordOAuthScope::Bot => "bot", + DiscordOAuthScope::RPCVoiceRead => "rpc.voice.read", + DiscordOAuthScope::RPCVideoWrite => "rpc.video.write", + } + } +} + +impl From<&DiscordOAuthScopes> for String { + fn from(value: &DiscordOAuthScopes) -> Self { + let scopes = &value.0; + + scopes + .iter() + .map(|scope| scope.into()) + .collect::>() + .join("+") + } +} + +impl Display for DiscordOAuthScopes { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "{}", String::from(self)) + } +} + +impl From for DiscordOAuthScopes { + fn from(value: DiscordOAuthScopeSet) -> Self { + match value { + DiscordOAuthScopeSet::Verification => DiscordOAuthScopes(vec![ + DiscordOAuthScope::Identify, + DiscordOAuthScope::GuildsMembersRead, + DiscordOAuthScope::Guilds, + DiscordOAuthScope::Connections, + ]), + } + } +} + +impl TryFrom<&str> for DiscordOAuthScopeSet { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "verification" => Ok(DiscordOAuthScopeSet::Verification), + _ => Err(format!("Invalid scope set: {}", value)), + } + } +} + +impl<'a> DiscordAuthorizationCodeRequestBody<'a> { + /// Create a new [`DiscordAuthorizationCodeRequestBody`] with the given authorization code and configuration. + pub(crate) fn new(code: &'a str, cfg: &'a Config) -> Self { + Self { + client_id: &cfg.oauth.discord.client_id, + redirect_uri: &cfg.oauth.discord.redirect_uri, + grant_type: "authorization_code", + client_secret: std::env::var(constants::env::DISCORD_CLIENT_SECRET).unwrap_or_else( + |_| panic!("{} must be set", constants::env::DISCORD_CLIENT_SECRET), + ), + code, + } + } + + /// Converts the request body into a query parameter string. + pub(crate) fn as_query_params(&self) -> String { + url!([ + ("client_id", self.client_id), + ("client_secret", self.client_secret), + ("grant_type", self.grant_type), + ("code", self.code), + ("redirect_uri", self.redirect_uri) + ]) + } +} + +impl<'a> DiscordRefreshTokenBody<'a> { + /// Create a new [`DiscordRefreshTokenBody`] with the given refresh token and configuration. + pub(crate) fn new(refresh_token: &'a str, cfg: &'a Config) -> Self { + Self { + grant_type: "refresh_token", + refresh_token, + client_id: &cfg.oauth.discord.client_id, + client_secret: std::env::var(constants::env::DISCORD_CLIENT_SECRET).unwrap_or_else( + |_| panic!("{} must be set", constants::env::DISCORD_CLIENT_SECRET), + ), + } + } + + /// Converts the request body into a query parameter string. + pub(crate) fn as_query_params(&self) -> String { + url!([ + ("grant_type", self.grant_type), + ("refresh_token", self.refresh_token), + ("client_id", self.client_id), + ("client_secret", self.client_secret) + ]) + } +} + +#[cfg(test)] +mod tests { + use super::{DiscordOAuthScope, DiscordOAuthScopeSet, DiscordOAuthScopes}; + + #[test] + fn test_discord_oauth_scopes_display() { + let scopes = DiscordOAuthScopes(vec![ + DiscordOAuthScope::Identify, + DiscordOAuthScope::GuildsMembersRead, + DiscordOAuthScope::Guilds, + DiscordOAuthScope::Connections, + ]); + + assert_eq!( + format!("{}", scopes), + "identify+guilds.members.read+guilds+connections" + ); + } + + #[test] + fn test_discord_oauth_scope_set_try_from() { + assert_eq!( + DiscordOAuthScopeSet::try_from("verification").unwrap(), + DiscordOAuthScopeSet::Verification + ); + } + + #[test] + fn test_discord_oauth_scopes_from() { + assert_eq!( + DiscordOAuthScopes::from(DiscordOAuthScopeSet::Verification), + DiscordOAuthScopes(vec![ + DiscordOAuthScope::Identify, + DiscordOAuthScope::GuildsMembersRead, + DiscordOAuthScope::Guilds, + DiscordOAuthScope::Connections, + ]) + ); + } +} diff --git a/src/oauth/types/mod.rs b/src/oauth/types/mod.rs new file mode 100644 index 0000000..ca8f6a6 --- /dev/null +++ b/src/oauth/types/mod.rs @@ -0,0 +1,22 @@ +use rocket::http::Cookie; + +pub(super) mod discord; +pub(super) mod roblox; + +/// The OAuth callback query parameters. +#[derive(Debug, FromForm)] +pub(super) struct OAuthCallback { + pub(super) code: String, + pub(super) state: String, +} + +/// Result of the [`generate_session`](crate::oauth::utils::generate_session) function. +#[derive(Debug)] +pub(super) struct GeneratedSession<'a> { + /// The session cookie. + pub(super) cookie: Cookie<'a>, + /// The session ID. + pub(super) session_id: String, + /// The expiration date of the session. + pub(super) expires_at: chrono::NaiveDateTime, +} diff --git a/src/oauth/types/roblox.rs b/src/oauth/types/roblox.rs new file mode 100644 index 0000000..ad11b76 --- /dev/null +++ b/src/oauth/types/roblox.rs @@ -0,0 +1,248 @@ +use crate::config::Config; +use crate::{constants, url}; +use rocket::serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +/// A set of scopes that will be requested by the web app. +/// +/// The internal API should parse these scope sets into individual scopes +/// that will be requested from the OAuth2 provider +#[derive(PartialEq, Debug)] +pub(crate) enum RobloxOAuthScopeSet { + Verification, +} + +/// A set of scopes that will be requested from the OAuth2 provider. +#[derive(PartialEq, Debug)] +pub(crate) struct RobloxOAuthScopes(pub(crate) Vec); + +/// A scope that will be requested from the OAuth2 provider. +#[derive(PartialEq, Debug)] +pub(crate) enum RobloxOAuthScope { + OpenID, + Profile, + AssetRead, + AssetWrite, + GroupRead, + GroupWrite, + LegacyBadgeManage, + LegacyDeveloperProductManage, + LegacyGamePassManage, + LegacyGroupManage, + LegacyTeamCollaborationManage, + LegacyUniverseManage, + LegacyUniverseBadgeWrite, + LegacyUniverseFollowingRead, + LegacyUniverseFollowingWrite, + LegacyUserManage, + UniverseMessagingServicePublish, + UniverseWrite, + UniversePlaceWrite, + UniverseSubscriptionProductSubscriptionRead, + UniverseUserRestrictionRead, + UniverseUserRestrictionWrite, + UserAdvancedRead, + UserCommerceItemRead, + UserCommerceItemWrite, + UserCommerceMerchantConnectionRead, + UserCommerceMerchantConnectionWrite, + UserInventoryItemRead, + UserSocialRead, + UserUserNotificationWrite, +} + +/// The request body for the Roblox OAuth token endpoint. +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct RobloxAuthorizedCodeRequestBody<'a> { + /// The authorization code received from the OAuth2 provider. + code: &'a str, + /// The code verifier used to generate the authorization code. + code_verifier: &'a str, + /// The grant type for the OAuth2 request. + grant_type: &'a str, + /// The client ID for the OAuth2 application. + client_id: &'a str, + /// The client secret for the OAuth2 application. + /// Must be an owned string as it is retrieved from the environment + client_secret: String, +} + +/// The response body for the Roblox OAuth token endpoint. +#[derive(Deserialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct RobloxAccessTokenResponse { + /// The access token for the user. + pub(crate) access_token: String, + /// The refresh token for the user. + pub(crate) refresh_token: String, + /// The time in seconds until the access token expires. + pub(crate) expires_in: i64, + /// The scopes that the user has authorized. + pub(crate) scope: String, +} + +/// The response body for the Roblox OAuth user info endpoint. +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct RobloxUserInfoResponse { + /// The user's Roblox ID. + #[serde(rename = "sub")] + pub(crate) id: String, +} + +impl From<&RobloxOAuthScope> for &str { + fn from(value: &RobloxOAuthScope) -> Self { + match value { + RobloxOAuthScope::OpenID => "openid", + RobloxOAuthScope::Profile => "profile", + RobloxOAuthScope::AssetRead => "creator-store-product:read", + RobloxOAuthScope::AssetWrite => "creator-store-product:write", + RobloxOAuthScope::GroupRead => "group:read", + RobloxOAuthScope::GroupWrite => "group:write", + RobloxOAuthScope::LegacyBadgeManage => "legacy-badge:manage", + RobloxOAuthScope::LegacyDeveloperProductManage => "legacy-developer-product:manage", + RobloxOAuthScope::LegacyGamePassManage => "legacy-game-pass:manage", + RobloxOAuthScope::LegacyGroupManage => "legacy-group:manage", + RobloxOAuthScope::LegacyTeamCollaborationManage => "legacy-team-collaboration:manage", + RobloxOAuthScope::LegacyUniverseManage => "legacy-universe:manage", + RobloxOAuthScope::LegacyUniverseBadgeWrite => "legacy-universe.badge:write", + RobloxOAuthScope::LegacyUniverseFollowingRead => "legacy-universe.following:read", + RobloxOAuthScope::LegacyUniverseFollowingWrite => "legacy-universe.following:write", + RobloxOAuthScope::LegacyUserManage => "legacy-user:manage", + RobloxOAuthScope::UniverseMessagingServicePublish => { + "universe-messaging-service:publish" + } + RobloxOAuthScope::UniverseWrite => "universe:write", + RobloxOAuthScope::UniversePlaceWrite => "universe.place:write", + RobloxOAuthScope::UniverseSubscriptionProductSubscriptionRead => { + "universe.subscription-product.subscription:read" + } + RobloxOAuthScope::UniverseUserRestrictionRead => "universe.user-restriction:read", + RobloxOAuthScope::UniverseUserRestrictionWrite => "universe.user-restriction:write", + RobloxOAuthScope::UserAdvancedRead => "user.advanced:read", + RobloxOAuthScope::UserCommerceItemRead => "user.commerce-item:read", + RobloxOAuthScope::UserCommerceItemWrite => "user.commerce-item:write", + RobloxOAuthScope::UserCommerceMerchantConnectionRead => { + "user.commerce-merchant-connection:read" + } + RobloxOAuthScope::UserCommerceMerchantConnectionWrite => { + "user.commerce-merchant-connection:write" + } + RobloxOAuthScope::UserInventoryItemRead => "user.inventory-item:read", + RobloxOAuthScope::UserSocialRead => "user.social:read", + RobloxOAuthScope::UserUserNotificationWrite => "user.user-notification:write", + } + } +} + +impl From<&RobloxOAuthScopes> for String { + fn from(value: &RobloxOAuthScopes) -> Self { + let scopes = &value.0; + + scopes + .iter() + .map(|scope| scope.into()) + .collect::>() + .join("%20") + } +} + +impl Display for RobloxOAuthScopes { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "{}", String::from(self)) + } +} + +impl From for RobloxOAuthScopes { + fn from(value: RobloxOAuthScopeSet) -> Self { + match value { + RobloxOAuthScopeSet::Verification => RobloxOAuthScopes(vec![ + RobloxOAuthScope::OpenID, + RobloxOAuthScope::Profile, + RobloxOAuthScope::GroupRead, + RobloxOAuthScope::UserInventoryItemRead, + RobloxOAuthScope::UserAdvancedRead, + ]), + } + } +} + +impl TryFrom<&str> for RobloxOAuthScopeSet { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "verification" => Ok(RobloxOAuthScopeSet::Verification), + _ => Err(format!("Invalid scope set: {}", value)), + } + } +} + +impl<'a> RobloxAuthorizedCodeRequestBody<'a> { + /// Creates a new [`RobloxAuthorizedCodeRequestBody`] with the given code, code verifier, and configuration. + pub(crate) fn new(code: &'a str, code_verifier: &'a str, cfg: &'a Config) -> Self { + Self { + code, + code_verifier, + grant_type: "authorization_code", + client_id: &cfg.oauth.roblox.client_id, + client_secret: std::env::var(constants::env::ROBLOX_CLIENT_SECRET) + .unwrap_or_else(|_| panic!("{} must be set", constants::env::ROBLOX_CLIENT_SECRET)), + } + } + + /// Converts the request body to a query parameter string. + pub(crate) fn as_query_params(&self) -> String { + url!([ + ("code", self.code), + ("code_verifier", self.code_verifier), + ("grant_type", self.grant_type), + ("client_id", self.client_id), + ("client_secret", self.client_secret) + ]) + } +} + +#[cfg(test)] +mod tests { + use super::{RobloxOAuthScope, RobloxOAuthScopeSet, RobloxOAuthScopes}; + + #[test] + fn test_roblox_oauth_scopes_display() { + let scopes = RobloxOAuthScopes(vec![ + RobloxOAuthScope::OpenID, + RobloxOAuthScope::Profile, + RobloxOAuthScope::GroupRead, + RobloxOAuthScope::UserInventoryItemRead, + RobloxOAuthScope::UserAdvancedRead, + ]); + + assert_eq!( + format!("{}", scopes), + "openid%20profile%20group:read%20user.inventory-item:read%20user.advanced:read" + ); + } + + #[test] + fn test_roblox_oauth_scope_set_try_from() { + assert_eq!( + RobloxOAuthScopeSet::try_from("verification").unwrap(), + RobloxOAuthScopeSet::Verification + ); + } + + #[test] + fn test_roblox_oauth_scopes_from() { + assert_eq!( + RobloxOAuthScopes::from(RobloxOAuthScopeSet::Verification), + RobloxOAuthScopes(vec![ + RobloxOAuthScope::OpenID, + RobloxOAuthScope::Profile, + RobloxOAuthScope::GroupRead, + RobloxOAuthScope::UserInventoryItemRead, + RobloxOAuthScope::UserAdvancedRead, + ]) + ); + } +} diff --git a/src/oauth/utils/discord.rs b/src/oauth/utils/discord.rs new file mode 100644 index 0000000..3157e57 --- /dev/null +++ b/src/oauth/utils/discord.rs @@ -0,0 +1,119 @@ +use crate::config::Config; +use crate::constants; +use crate::oauth::types::discord::{ + DiscordAccessTokenResponse, DiscordAuthorizationCodeRequestBody, DiscordAuthorizedUserResponse, + DiscordOAuthScopes, DiscordRefreshTokenBody, DiscordUser, +}; +use crate::response::ApiError; +use crate::url; +use rocket::http::Status; + +/// Constructs the Discord OAuth URL with the given scopes and state. +/// The scopes are joined by a plus sign (`+`). +/// +/// # Arguments +/// +/// * `scopes` - The scopes to request from the user. +/// * `state` - The state to send to the Discord OAuth server. +/// +/// # Returns +/// +/// The constructed Discord OAuth URL. +pub(crate) fn construct_discord_oauth_url( + scopes: DiscordOAuthScopes, + state: &str, + cfg: &Config, +) -> String { + url!( + constants::discord_api::AUTHORIZE_URL, + [ + ("redirect_uri", cfg.oauth.discord.redirect_uri), + ("client_id", cfg.oauth.discord.client_id), + ("response_type", "code"), + ("scope", scopes), + ("state", state) + ] + ) +} + +/// Exchanges the given code for an access token. +/// +/// # Arguments +/// +/// * `code` - The code to exchange for an access token. +/// * `cfg` - The application configuration. +/// +/// # Returns +/// +/// The [`DiscordAccessTokenResponse`] struct if the exchange was successful, an [`ApiError`] otherwise. +pub(crate) fn exchange_code( + code: &str, + cfg: &Config, +) -> Result { + let body = DiscordAuthorizationCodeRequestBody::new(code, cfg); + let response = minreq::post(constants::discord_api::TOKEN_URL) + .with_header("Content-Type", "application/x-www-form-urlencoded") + .with_body(body.as_query_params()) + .send() + .map_err(|_| ApiError::message(Status::BadGateway, "Failed to exchange code for token"))?; + + response.json::().map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to parse token response", + ) + }) +} + +/// Refreshes the given refresh token. +/// +/// # Arguments +/// +/// * `refresh_token` - The refresh token to use. +/// * `cfg` - The application configuration. +/// +/// # Returns +/// +/// The [`DiscordAccessTokenResponse`] struct if the refresh was successful, an [`ApiError`] otherwise. +pub(crate) fn refresh_token( + refresh_token: &str, + cfg: &Config, +) -> Result { + let body = DiscordRefreshTokenBody::new(refresh_token, cfg); + let response = minreq::post(constants::discord_api::TOKEN_URL) + .with_header("Content-Type", "application/x-www-form-urlencoded") + .with_body(body.as_query_params()) + .send() + .map_err(|_| ApiError::message(Status::BadGateway, "Failed to refresh token"))?; + + response.json::().map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to parse token refresh response", + ) + }) +} + +/// Gets the authorized user from the Discord API. +/// +/// # Arguments +/// +/// * `access_token` - The access token to use. +/// +/// # Returns +/// +/// The [`DiscordUser`] struct if the request was successful, an [`ApiError`] otherwise. +pub(crate) fn get_authorized_user(access_token: &str) -> Result, ApiError> { + minreq::get(constants::discord_api::USER_URL) + .with_header("Authorization", format!("Bearer {}", access_token)) + .send() + .map_err(|_| ApiError::message(Status::BadGateway, "Failed to obtain Discord user info"))? + .json::() + .map(|r| r.user) + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to parse Discord user info", + ) + }) +} diff --git a/src/oauth/utils/mod.rs b/src/oauth/utils/mod.rs new file mode 100644 index 0000000..54e3cfe --- /dev/null +++ b/src/oauth/utils/mod.rs @@ -0,0 +1,47 @@ +pub(super) mod discord; +pub(super) mod pixy; +pub(super) mod roblox; + +use self::pixy::generate_random_base64_url_safe_no_pad_string; +use crate::constants; +use crate::oauth::types::GeneratedSession; +use rand::{thread_rng, Rng}; +use rocket::http::{Cookie, SameSite}; + +/// Generates a random state. +/// +/// The state is a random base64 URL-safe string with no padding and a length between 10 and 20 bytes. +pub(super) fn generate_state() -> String { + let number_of_bytes = thread_rng().gen_range(10..=20); + generate_random_base64_url_safe_no_pad_string(number_of_bytes) +} + +/// Generates a random session ID. +/// +/// The session ID is a random base64 URL-safe string with no padding and a length between 32 and 64 bytes. +fn generate_session_id() -> String { + let number_of_bytes = thread_rng().gen_range(32..=64); + generate_random_base64_url_safe_no_pad_string(number_of_bytes) +} + +/// Generates a new session. +/// +/// - The session ID is a random base64 URL-safe string with no padding and a length between 32 and 64 bytes. +/// - The session cookie has the session ID as the value and a max age of 30 days. +/// +/// # Returns +/// +/// A [`GeneratedSession`] struct containing the session cookie, session ID, and the expiration date. +pub(super) fn generate_session() -> GeneratedSession<'static> { + let session_id = generate_session_id(); + let expires_at = chrono::Utc::now() + chrono::Duration::days(30); + + GeneratedSession { + cookie: Cookie::build((constants::cookie::SESSION_ID, session_id.clone())) + .max_age(rocket::time::Duration::days(30)) + .same_site(SameSite::Lax) + .build(), + session_id, + expires_at: expires_at.naive_utc(), + } +} diff --git a/src/oauth/utils/pixy.rs b/src/oauth/utils/pixy.rs new file mode 100644 index 0000000..b3482a3 --- /dev/null +++ b/src/oauth/utils/pixy.rs @@ -0,0 +1,61 @@ +use rand::{thread_rng, Rng}; +use sha2::{Digest, Sha256}; + +/// Generates a random base64 URL-safe string with no padding. +/// +/// # Arguments +/// +/// - `number_of_bytes` - The number of random bytes to generate. +pub(crate) fn generate_random_base64_url_safe_no_pad_string(number_of_bytes: usize) -> String { + let mut random_bytes = vec![0u8; number_of_bytes]; + + thread_rng().fill(&mut random_bytes[..]); + + base64::encode_config(&random_bytes, base64::URL_SAFE_NO_PAD) +} + +/// Generates a random base64 URL-safe string with no padding and a length between 32 and 96 bytes. +/// The resulting string is wrapped in a [`SecretString`]. +fn generate_verifier() -> String { + let number_of_bytes = thread_rng().gen_range(32..=96); + + generate_random_base64_url_safe_no_pad_string(number_of_bytes) +} + +/// Calculates the challenge from the verifier. +/// The challenge is the SHA-256 hash of the verifier. +/// The challenge is then base64 encoded with URL-safe characters and no padding. +/// +/// # Arguments +/// +/// - `verifier` - The verifier to calculate the challenge from. +/// +/// # Returns +/// +/// The challenge as a base64 URL-safe string with no padding. +fn calculate_challenge_from_verifier(verifier: &str) -> String { + let verifier_hash = Sha256::digest(verifier.as_bytes()); + + base64::encode_config(&verifier_hash, base64::URL_SAFE_NO_PAD) +} + +/// A struct that represents the Pixy PKCE method. +pub(crate) struct Pixy { + /// The challenge generated by the Pixy method. + pub(crate) challenge: String, + /// The verifier generated by the Pixy method. + pub(crate) verifier: String, +} + +impl Pixy { + /// Creates a new Pixy instance. + pub(crate) fn new() -> Self { + let verifier = generate_verifier(); + let challenge = calculate_challenge_from_verifier(&verifier); + + Pixy { + challenge, + verifier, + } + } +} diff --git a/src/oauth/utils/roblox.rs b/src/oauth/utils/roblox.rs new file mode 100644 index 0000000..49e8251 --- /dev/null +++ b/src/oauth/utils/roblox.rs @@ -0,0 +1,99 @@ +use crate::config::Config; +use crate::constants; +use crate::oauth::types::roblox::{ + RobloxAccessTokenResponse, RobloxAuthorizedCodeRequestBody, RobloxOAuthScopes, + RobloxUserInfoResponse, +}; +use crate::response::ApiError; +use crate::url; +use rocket::http::Status; + +/// Constructs the Roblox OAuth URL with the given scopes and state. +/// The scopes are joined by an encoded space (`%20`). +/// +/// # Arguments +/// +/// * `scopes` - The scopes to request from the user. +/// * `state` - The state to send to the Roblox OAuth server. +/// +/// # Returns +/// +/// The constructed Roblox OAuth URL. +pub(crate) fn construct_roblox_oauth_url( + code_challenge: &str, + scopes: RobloxOAuthScopes, + state: &str, + cfg: &Config, +) -> String { + url!( + constants::roblox_api::AUTHORIZE_URL, + [ + ("redirect_uri", cfg.oauth.roblox.redirect_uri), + ("client_id", cfg.oauth.roblox.client_id), + ("code_challenge_method", "S256"), + ("code_challenge", code_challenge), + ("response_type", "code"), + ("state", state), + ("scope", scopes) + ] + ) +} + +/// Exchanges the given code for an access token. +/// +/// # Arguments +/// +/// * `code` - The code to exchange for an access token. +/// * `code_verifier` - The code verifier used to generate the code challenge. +/// * `cfg` - The application configuration. +/// +/// # Returns +/// +/// The [`RobloxAccessTokenResponse`] struct if the exchange was successful, an [`ApiError`] otherwise. +pub(crate) fn exchange_code( + code: &str, + code_verifier: &str, + cfg: &Config, +) -> Result { + let body = RobloxAuthorizedCodeRequestBody::new(code, code_verifier, cfg); + let response = minreq::post(constants::roblox_api::TOKEN_URL) + .with_header("Content-Type", "application/x-www-form-urlencoded") + .with_body(body.as_query_params()) + .send() + .map_err(|_| ApiError::message(Status::BadGateway, "Failed to exchange code for token"))?; + + response.json::().map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to parse token response", + ) + }) +} + +/// Gets the authorized user from the Roblox API. +/// +/// # Arguments +/// +/// * `access_token` - The access token to use. +/// +/// # Returns +/// +/// The [`RobloxUserInfoResponse`] struct if the request was successful, an [`ApiError`] otherwise. +pub(crate) fn get_authorized_user(access_token: &str) -> Result { + minreq::get(constants::roblox_api::USER_URL) + .with_header("Authorization", format!("Bearer {access_token}")) + .send() + .map_err(|_| { + ApiError::message( + Status::BadGateway, + "Failed to get authorized user information", + ) + })? + .json::() + .map_err(|_| { + ApiError::message( + Status::InternalServerError, + "Failed to parse authorized user information", + ) + }) +} diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..6fdb60d --- /dev/null +++ b/src/response.rs @@ -0,0 +1,183 @@ +use rocket::http::{ContentType, Status}; +use rocket::response; +use rocket::serde::json::serde_json::json; +use rocket::serde::json::Value; +use rocket::serde::Serialize; +use rocket::{Request, Response}; + +/// A type alias for API results, which can either be a successful response of type `R` +/// or an [`ApiError`]. Type `R` **must** implement the [`Serialize`] trait. +/// +/// The default type for `R` is [`ApiResponse`]. +pub(crate) type ApiResult = Result; + +/// Encapsulates error information that can be returned +/// by the API. It includes a status code and a message describing the error. +#[derive(Serialize, Debug)] +#[serde(crate = "rocket::serde")] +pub(crate) struct ApiError { + /// The HTTP status code associated with the error. + code: u16, + /// A message describing the error. + message: String, +} + +impl ApiError { + /// Creates a new [`ApiError`] with the given status code and its string representation as the message. + /// + /// # Arguments + /// + /// * `status` - The HTTP status to be used for the error. + /// + /// # Returns + /// + /// A new [`ApiError`] instance with the provided status code and message. + pub(crate) fn status(status: Status) -> Self { + Self { + code: status.code, + message: status.to_string(), + } + } + + /// Creates a new [`ApiError`] with the given status code and a custom message. + /// + /// # Arguments + /// + /// * `status` - The HTTP status to be used for the error. + /// * `message` - A custom message describing the error. + /// + /// # Returns + /// + /// A new [`ApiError`] instance with the provided status code and custom message. + pub(crate) fn message(status: Status, message: M) -> Self + where + M: Into, + { + Self { + code: status.code, + message: message.into(), + } + } +} + +impl<'r> response::Responder<'r, 'r> for ApiError { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'r> { + // We can assume that the status code is valid since it's coming from the Status enum + let status = Status::from_code(self.code).unwrap(); + rocket::info!(" >> Error message: {}", self.message); + let error = json!({ "error": self }); + + Response::build_from(error.respond_to(req)?) + .status(status) + .header(ContentType::JSON) + .ok() + } +} + +/// Encapsulates a successful response that can be returned +/// by the API. It includes a status code and optional data. +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub(crate) struct ApiResponse { + /// The HTTP status code associated with the response. + code: u16, + /// Optional data included in the response. + data: Option, +} + +impl ApiResponse { + /// Creates a new [`ApiResponse`] with the given status code and no data. + /// + /// # Arguments + /// + /// * `status` - The HTTP status to be used for the response. + /// + /// # Returns + /// + /// A new [`ApiResponse`] instance with the provided status code and no data. + pub(crate) fn status(status: Status) -> Self { + Self { + code: status.code, + data: None, + } + } + + /// Creates a new [`ApiResponse`] with a status code of `200 OK` and the given data. + /// + /// # Arguments + /// + /// * `data` - The data to be included in the response. Must implement the [`Serialize`](rocket::serde::Serialize) trait. + /// + /// # Returns + /// + /// A new [`ApiResponse`] instance with a status code of `200 OK` and the provided data. + pub(crate) fn ok(data: D) -> Self + where + D: Serialize, + { + let data = + rocket::serde::json::to_value(&data).expect("Failed to serialize data for ApiResponse"); + Self { + code: Status::Ok.code, + data: Some(data), + } + } +} + +impl<'r> response::Responder<'r, 'r> for ApiResponse { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'r> { + // We can assume that the status code is valid since it's coming from the Status enum + let status = Status::from_code(self.code).unwrap(); + + if let Some(data) = &self.data { + rocket::info!(" >> Response: {}", data); + } + + // We can assume that the data is valid JSON since it's coming from serde_json + let data = rocket::serde::json::to_value(self).unwrap(); + + Response::build_from(data.respond_to(req)?) + .status(status) + .header(ContentType::JSON) + .ok() + } +} + +#[cfg(test)] +mod tests { + use super::{ApiError, ApiResponse}; + use rocket::http::Status; + use rocket::serde::json::Value; + + #[test] + fn test_api_error_status() { + let status = Status::BadRequest; + let error = ApiError::status(status); + assert_eq!(error.code, status.code); + assert_eq!(error.message, status.to_string()); + } + + #[test] + fn test_api_error_message() { + let status = Status::NotFound; + let error = ApiError::message(status, "Resource not found"); + assert_eq!(error.code, status.code); + assert_eq!(error.message, "Resource not found"); + } + + #[test] + fn test_api_response_status() { + let status = Status::Created; + let response = ApiResponse::status(status); + assert_eq!(response.code, status.code); + assert!(response.data.is_none()); + } + + #[test] + fn test_api_response_ok() { + let data = "Hello, world!".to_string(); + let response = ApiResponse::ok(&data); + assert_eq!(response.code, Status::Ok.code); + assert_eq!(response.data, Some(Value::String(data))); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..2185835 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,37 @@ +use crate::constants; + +/// Constructs a URL with query parameters. +/// +/// This macro can be used to create a URL with query parameters from a domain and a list of key-value pairs, +/// or just from a list of key-value pairs. +/// +/// # Examples +/// +/// ``` +/// # use roops_internal_api::url; +/// # +/// let url = url!("https://example.com", [("key1", "value1"), ("key2", "value2")]); +/// assert_eq!(url, "https://example.com?key1=value1&key2=value2"); +/// +/// let url = url!([("key1", "value1"), ("key2", "value2")]); +/// assert_eq!(url, "key1=value1&key2=value2"); +/// ``` +#[macro_export] +macro_rules! url { + ($domain:expr, [$(($key:expr, $value:expr)),*]) => { + format!("{}?{}", $domain, vec![$(format!("{}={}", $key, $value)),*].join("&")) + }; + ([$(($key:expr, $value:expr)),*]) => { + vec![$(format!("{}={}", $key, $value)),*].join("&") + }; +} + +/// Constructs a route with the API version. +/// +/// Does not include a slash between the API version and the route. +pub(crate) fn construct_api_route(route: R) -> String +where + R: AsRef, +{ + format!("/{}{}", constants::API_VERSION, route.as_ref()) +}