diff --git a/Cargo.lock b/Cargo.lock index fce1608e2..06aa943db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -669,7 +669,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -699,7 +699,7 @@ dependencies = [ [[package]] name = "azalia" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "azalia-config", "azalia-log", @@ -712,7 +712,7 @@ dependencies = [ [[package]] name = "azalia-config" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "azalia-config-derive", ] @@ -720,21 +720,21 @@ dependencies = [ [[package]] name = "azalia-config-derive" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] name = "azalia-log" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "cfg-if", "chrono", - "owo-colors 4.0.0", + "owo-colors 4.1.0", "serde_json", "tracing", "tracing-log", @@ -744,7 +744,7 @@ dependencies = [ [[package]] name = "azalia-remi" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "azure_core", "mongodb", @@ -758,7 +758,7 @@ dependencies = [ [[package]] name = "azalia-serde" version = "0.1.0" -source = "git+https://github.com/Noelware/azalia?rev=06e3b85a496854086803917e263bc5427f8324dd#06e3b85a496854086803917e263bc5427f8324dd" +source = "git+https://github.com/Noelware/azalia?rev=1a722a0e785e4abfad62bce6738516e47afce89c#1a722a0e785e4abfad62bce6738516e47afce89c" dependencies = [ "serde", "tracing", @@ -924,7 +924,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.76", + "syn 2.0.77", "which 4.4.2", ] @@ -1159,7 +1159,7 @@ dependencies = [ "clap_complete", "eyre", "num_cpus", - "owo-colors 4.0.0", + "owo-colors 4.1.0", "remi-azure", "remi-fs", "remi-s3", @@ -1218,6 +1218,7 @@ dependencies = [ "diesel_migrations", "eyre", "sentry", + "serde", "tracing", ] @@ -1299,6 +1300,7 @@ dependencies = [ "axum", "axum-server", "azalia", + "base64 0.22.1", "charted-authz", "charted-config", "charted-core", @@ -1306,8 +1308,10 @@ dependencies = [ "charted-features", "charted-helm-charts", "charted-types", + "diesel", "eyre", "inventory", + "jsonwebtoken", "mime", "multer", "sentry", @@ -1346,7 +1350,7 @@ checksum = "6ab8eb39550a630fa3d0bdbbc581a78de8fcb5c5207c3ea45b26290cd1a39f88" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1358,6 +1362,7 @@ dependencies = [ "charted-database", "chrono", "diesel", + "diesel-derive-enum", "paste", "schemars", "semver 1.0.23", @@ -1444,7 +1449,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1607,6 +1612,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -1778,7 +1794,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1800,7 +1816,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1860,7 +1876,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.0", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1880,15 +1896,15 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "unicode-xid", ] [[package]] name = "diesel" -version = "2.2.3" +version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e13bab2796f412722112327f3e575601a3e9cdcbe426f0d30dbf43f3f5dc71" +checksum = "158fe8e2e68695bd615d7e4f3227c0727b151330d3e253b525086c348d055d5e" dependencies = [ "bitflags 2.6.0", "byteorder", @@ -1902,6 +1918,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "diesel-derive-enum" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81c5131a2895ef64741dad1d483f358c2a229a3a2d1b256778cdc5e146db64d4" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "diesel_derives" version = "2.2.3" @@ -1912,7 +1940,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1932,7 +1960,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" dependencies = [ - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1995,7 +2023,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2300,7 +2328,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2979,6 +3007,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kstring" version = "2.0.2" @@ -3009,7 +3052,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3352,6 +3395,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3424,7 +3477,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3482,11 +3535,12 @@ checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" [[package]] name = "owo-colors" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" dependencies = [ - "supports-color", + "supports-color 2.1.0", + "supports-color 3.0.1", ] [[package]] @@ -3551,7 +3605,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3580,6 +3634,16 @@ dependencies = [ "digest", ] +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3603,7 +3667,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3665,7 +3729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" dependencies = [ "proc-macro2", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4247,7 +4311,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4476,7 +4540,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4487,7 +4551,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4532,7 +4596,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4605,7 +4669,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4709,6 +4773,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -4804,7 +4880,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4815,7 +4891,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4834,7 +4910,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4853,6 +4929,15 @@ dependencies = [ "is_ci", ] +[[package]] +name = "supports-color" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "1.0.109" @@ -4866,9 +4951,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.76" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -4934,6 +5019,29 @@ dependencies = [ "xattr", ] +[[package]] +name = "tatsuki" +version = "0.1.0" +dependencies = [ + "chrono", + "cron", + "log", + "pin-project-lite", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "tatsuki-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "tempfile" version = "3.12.0" @@ -5029,7 +5137,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5114,7 +5222,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5161,9 +5269,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", @@ -5235,11 +5343,12 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "41515cc9e193536d93fd0dbbea0c73819c08eca76e0b30909a325c3ec90985bb" dependencies = [ "async-compression", + "base64 0.22.1", "bitflags 2.6.0", "bytes", "futures-core", @@ -5247,6 +5356,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.1", "http-body-util", + "mime", "pin-project-lite", "tokio", "tokio-util", @@ -5287,7 +5397,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5533,7 +5643,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "uuid", ] @@ -5629,7 +5739,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "wasm-bindgen-shared", ] @@ -5663,7 +5773,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6042,7 +6152,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6062,5 +6172,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] diff --git a/Cargo.toml b/Cargo.toml index b0b4259a1..832497939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ [workspace] resolver = "2" -members = ["crates/*", "crates/authz/*"] +members = ["crates/*", "crates/authz/*", "crates/tatsuki/macros"] [workspace.package] version = "0.1.0" @@ -43,7 +43,7 @@ chrono = { version = "0.4.23", features = ["serde"] } clap = { version = "4.5.16", features = ["derive", "env"] } clap_complete = "4.5.26" derive_more = "1.0.0" -diesel = "2.2.3" +diesel = { version = "2.2.4", features = ["postgres", "sqlite", "chrono"] } eyre = "0.6.12" multer = "3.1.0" remi = "0.8.0" @@ -72,4 +72,10 @@ which = "6.0.3" [workspace.dependencies.azalia] version = "0.1.0" git = "https://github.com/Noelware/azalia" -rev = "06e3b85a496854086803917e263bc5427f8324dd" +rev = "1a722a0e785e4abfad62bce6738516e47afce89c" + +[profile.release] +opt-level = "z" +strip = true +debug = 0 +lto = "thin" diff --git a/app/README.md b/app/README.md new file mode 100644 index 000000000..9c6659b89 --- /dev/null +++ b/app/README.md @@ -0,0 +1,2 @@ +# 🐻‍❄️⭐ Hoshi +**Hoshi** is the main web interface for [charted-server](..). It is built with Vue and Vite, and is a single-page application. diff --git a/crates/authz/lib.rs b/crates/authz/lib.rs index 377c8d805..81ebf7c49 100644 --- a/crates/authz/lib.rs +++ b/crates/authz/lib.rs @@ -25,5 +25,6 @@ impl Error for InvalidPassword {} /// Trait that allows to build an authenticator that allows to authenticate users. pub trait Authenticator: Send + Sync { - fn authenticate(&self, user: User, password: String) -> BoxedFuture>; + /// Authenticate a given [`User`] with the password given. + fn authenticate<'u>(&'u self, user: &'u User, password: String) -> BoxedFuture<'u, eyre::Result<()>>; } diff --git a/crates/authz/local/lib.rs b/crates/authz/local/lib.rs index d8f702a68..223e63911 100644 --- a/crates/authz/local/lib.rs +++ b/crates/authz/local/lib.rs @@ -21,15 +21,15 @@ use tracing::error; pub struct Backend; impl charted_authz::Authenticator for Backend { - fn authenticate(&self, user: User, password: String) -> charted_core::BoxedFuture> { + fn authenticate<'u>(&'u self, user: &'u User, password: String) -> charted_core::BoxedFuture<'u, eyre::Result<()>> { Box::pin(async move { - let Some(pass) = user.password else { + let Some(ref pass) = user.password else { return Err(eyre!( "missing `password` field, did you migrate all users from previous backends?" )); }; - let hash = PasswordHash::new(&pass) + let hash = PasswordHash::new(pass) .inspect_err(|e| { error!(error = %e, "failed to compute argon2 hash for password"); }) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b26af8866..ac830fb0b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -42,7 +42,7 @@ clap = { workspace = true, features = ["derive", "env"] } clap_complete.workspace = true eyre.workspace = true num_cpus = "1.16.0" -owo-colors = { version = "4.0.0", features = ["supports-colors"] } +owo-colors = { version = "4.1.0", features = ["supports-colors"] } remi-azure = { workspace = true, features = ["tracing"] } remi-fs = { workspace = true, features = ["tracing"] } remi-s3 = { workspace = true, features = ["tracing"] } diff --git a/crates/cli/src/cmds/server.rs b/crates/cli/src/cmds/server.rs index 18c65a0a8..780418622 100644 --- a/crates/cli/src/cmds/server.rs +++ b/crates/cli/src/cmds/server.rs @@ -87,7 +87,7 @@ pub async fn run(Args { config, .. }: Args) -> eyre::Result<()> { storage::Config::S3(s3) => azalia::remi::StorageService::S3(remi_s3::StorageService::new(s3)), }; - azalia::remi::remi::StorageService::init(&storage).await?; + azalia::remi::core::StorageService::init(&storage).await?; info!("initialized data storage successfully!"); info!("initializing authz backend..."); diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index 615b545ed..4e52d0d01 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -199,6 +199,9 @@ pub enum ErrorCode { /// was unable to decode expected Base64 data. UnableToDecodeBase64, + /// was unable to decode into a ULID. + UnableToDecodeUlid, + /// received invalid UTF-8 data InvalidUtf8, diff --git a/crates/core/src/bitflags.rs b/crates/core/src/bitflags.rs index a5062c01d..1e9d0f728 100644 --- a/crates/core/src/bitflags.rs +++ b/crates/core/src/bitflags.rs @@ -38,6 +38,12 @@ impl Bitfield { // Since both `ApiKeyScope` and `MemberPermission` use `u64` as its `Bit` type, // we will do our own silly impls here. impl> Bitfield { + /// Returns all the possible enabled bits in the bitfield to determine + pub fn flags(&self) -> Vec<(&'static str, F::Bit)> { + let flags = F::flags(); + flags.into_iter().filter(|(_, bit)| self.contains(*bit)).collect() + } + /// Adds multiple bits to this [`Bitfield`] and updating the current /// value to what was acculumated. /// @@ -127,6 +133,17 @@ impl> Bitfield { self.0 &= min(removed, 0) } + + /// Determines if `bit` is contained in the inner bit. + pub fn contains>(&self, bit: B) -> bool { + (self.value() & bit.into()) != 0 + } +} + +impl> Default for Bitfield { + fn default() -> Self { + Bitfield(u64::default(), PhantomData) + } } /// Trait that is implemented by the [`bitflags`][bitflags] macro. @@ -197,141 +214,3 @@ macro_rules! bitflags { } }; } - -/* -// mod apikeyscope; -// pub use apikeyscope::*; - -// use std::{ -// collections::HashMap, -// marker::PhantomData, -// ops::{Add, BitAnd, BitOrAssign}, -// }; - -// #[derive(Debug, Clone)] -// pub struct Bitfield { -// value: F::Bit, -// _marker: PhantomData, -// } - -// impl Bitfield { -// /// Creates a new [`Bitfield`] instance with a given value. -// pub const fn new(value: F::Bit) -> Bitfield { -// Bitfield { -// value, -// _marker: PhantomData, -// } -// } - -// /* -// ` where ::Bit: std::ops::BitOrAssign` - -// let mut bits = bits.into_iter(); -// let first = bits.next(); -// if first.is_none() { -// return; -// } - -// let mut additional = 0u64; -// additional |= first.unwrap(); - -// let max = self.max(); -// for bit in bits { -// if bit == u64::MAX { -// continue; -// } - -// if bit > max { -// continue; -// } - -// additional |= bit; -// } - -// self.value |= additional; -// */ -// pub fn contains>(&self, value: I) -> bool -// where -// ::Bit: BitAnd, -// <::Bit as BitAnd>::Output: PartialEq, -// { -// let value = value.into(); -// (self.value & value) != 0 -// } -// } - -// impl Add for Bitfield -// where -// F::Bit: PartialEq, -// F::Bit: std::ops::BitOrAssign, -// { -// type Output = Bitfield; - -// fn add(mut self, rhs: Self) -> Self::Output { -// if rhs.value == u64::MAX { -// return self; -// } - -// self.value |= rhs.value; -// Bitfield { -// value: self.value, -// _marker: PhantomData, -// } -// } -// } - -/* - ::Bit: BitAnd, - <::Bit as BitAnd>::Output: PartialEq, - - pub fn add>(mut self, values: S) -> Bitfield - where - ::Bit: BitOrAssign, - { - let mut bits = values.into_iter(); - let mut value = 0u64; - - for element in bits { - if element == u64::MAX { - continue; - } - - value |= element; - } - - self.value |= value; - Bitfield { - value: self.value, - _marker: PhantomData, - } - } -*/ - -// fn heck() { -// let bitfield = Bitfield::::new(0); -// bitfield.contains(apikeyscope::ApiKeyScope::AdminOrgDelete); -// } - -// impl Bitfield { -// /// Creates a new [`Bitfield`] object. -// pub const fn new(value: F::Bit) -> Bitfield { -// Bitfield { -// value, -// _marker: PhantomData, -// } -// } - -// pub fn contains(&self, value: F::Bit) -> bool -// where -// F::Bit: BitAnd, -// ::Output: PartialEq, -// { -// (self.value & value) != 0 -// } -// } - -// fn test() { -// let bit = Bitfield::::new(0); -// bit.contains(apikeyscope::ApiKeyScope::AdminOrgDelete as u64); -// } -*/ diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml index 9b7317b86..0c1d2c118 100644 --- a/crates/database/Cargo.toml +++ b/crates/database/Cargo.toml @@ -37,4 +37,5 @@ diesel = { workspace = true, features = [ diesel_migrations = { version = "2.2.0", features = ["postgres", "sqlite"] } eyre.workspace = true sentry.workspace = true +serde.workspace = true tracing.workspace = true diff --git a/crates/database/migrations/postgresql/2024-08-19-024955_init/up.sql b/crates/database/migrations/postgresql/2024-08-19-024955_init/up.sql index de4940fdb..dec929ed7 100644 --- a/crates/database/migrations/postgresql/2024-08-19-024955_init/up.sql +++ b/crates/database/migrations/postgresql/2024-08-19-024955_init/up.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS "users"( verified_publisher BOOLEAN NOT NULL DEFAULT false, gravatar_email TEXT NULL DEFAULT NULL, description VARCHAR(240) NULL DEFAULT NULL, - avatar_hash TEXT NOT NULL DEFAULT NULL, + avatar_hash TEXT NULL DEFAULT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT(NOW()), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT(NOW()), username VARCHAR(64) NOT NULL, @@ -34,9 +34,9 @@ SELECT diesel_manage_updated_at('users'); CREATE TABLE IF NOT EXISTS "user_connections"( noelware_account_id BIGINT NULL DEFAULT NULL, - google_account_id TEXT NOT NULL DEFAULT NULL, - github_account_id TEXT NOT NULL DEFAULT NULL, - apple_account_id TEXT NOT NULL DEFAULT NULL, + google_account_id TEXT NULL DEFAULT NULL, + github_account_id TEXT NULL DEFAULT NULL, + apple_account_id TEXT NULL DEFAULT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT(NOW()), updated_at TIMESTAMPTZ NOT NULL DEFAULT(NOW()), account TEXT NOT NULL, diff --git a/crates/database/migrations/sqlite/2024-08-19-030100_init/up.sql b/crates/database/migrations/sqlite/2024-08-19-030100_init/up.sql index 7aa413906..8eae437ed 100644 --- a/crates/database/migrations/sqlite/2024-08-19-030100_init/up.sql +++ b/crates/database/migrations/sqlite/2024-08-19-030100_init/up.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS `users`( verified_publisher BOOLEAN NOT NULL DEFAULT false, gravatar_email TEXT NULL DEFAULT NULL, description VARCHAR(240) NULL DEFAULT NULL, - avatar_hash TEXT NOT NULL DEFAULT NULL, + avatar_hash TEXT NULL DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT(NOW()), updated_at DATETIME NOT NULL DEFAULT(NOW()), username VARCHAR(64) NOT NULL, @@ -33,9 +33,9 @@ CREATE UNIQUE INDEX idx_users_email ON users(email); CREATE TABLE IF NOT EXISTS `user_connections`( noelware_account_id BIGINT NULL DEFAULT NULL, - google_account_id TEXT NOT NULL DEFAULT NULL, - github_account_id TEXT NOT NULL DEFAULT NULL, - apple_account_id TEXT NOT NULL DEFAULT NULL, + google_account_id TEXT NULL DEFAULT NULL, + github_account_id TEXT NULL DEFAULT NULL, + apple_account_id TEXT NULL DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT(NOW()), updated_at DATETIME NOT NULL DEFAULT(NOW()), account TEXT NOT NULL, diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index 9ca0fe66e..2d0c30b03 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -82,21 +82,48 @@ pub fn version(pool: &DbPool) -> eyre::Result { #[macro_export] macro_rules! connection { + (@raw $conn:ident { + $( + $db:ident($c:ident) => $code:expr; + )* + }) => {{ + #[allow(unused)] + use ::diesel::prelude::*; + match *$conn { + $( + $crate::DbConnection::$db(ref mut $c) => $code, + )* + } + }}; + + (@raw $conn:ident { + $( + $db:ident($c:ident) $code:block; + )* + }) => {{ + #[allow(unused)] + use ::diesel::prelude::*; + match *$conn { + $( + $crate::DbConnection::$db(ref mut $c) => $code, + )* + } + }}; + ($pool:ident, { $( $db:ident($conn:ident) $code:block; )* }) => {{ #[allow(unused)] - use ::diesel::prelude::*; use ::eyre::Context; - let mut conn = ($pool).get().context("failed to get connection from pool")?; - match *conn { + let mut conn = ($pool).get().context("failed to get db connection")?; + $crate::connection!(@raw conn { $( - $crate::DbConnection::$db(ref mut $conn) => $code, + $db($conn) $code; )* - } + }) }}; } diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 4d18a3f67..8b4a14bfc 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -18,7 +18,7 @@ pub mod sqlite; /// All of the custom SQL types pub mod sql_types { - #[derive(Debug, diesel::QueryId, diesel::SqlType)] + #[derive(Debug, diesel::QueryId, diesel::SqlType, diesel::FromSqlRow)] #[diesel(postgres_type(name = "chart_type"))] #[diesel(sqlite_type(name = "Text"))] pub struct ChartType; diff --git a/crates/database/src/schema/postgresql.rs b/crates/database/src/schema/postgresql.rs index b75f4f1a1..9dacef9f5 100644 --- a/crates/database/src/schema/postgresql.rs +++ b/crates/database/src/schema/postgresql.rs @@ -141,9 +141,9 @@ diesel::table! { user_connections (id) { noelware_account_id -> Nullable, - google_account_id -> Text, - github_account_id -> Text, - apple_account_id -> Text, + google_account_id -> Nullable, + github_account_id -> Nullable, + apple_account_id -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, account -> Text, @@ -160,7 +160,7 @@ diesel::table! { gravatar_email -> Nullable, #[max_length = 240] description -> Nullable, - avatar_hash -> Text, + avatar_hash -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, #[max_length = 64] diff --git a/crates/database/src/schema/sqlite.rs b/crates/database/src/schema/sqlite.rs index 0af9cd8b0..1287d4559 100644 --- a/crates/database/src/schema/sqlite.rs +++ b/crates/database/src/schema/sqlite.rs @@ -133,9 +133,9 @@ diesel::table! { user_connections (id) { noelware_account_id -> Nullable, - google_account_id -> Text, - github_account_id -> Text, - apple_account_id -> Text, + google_account_id -> Nullable, + github_account_id -> Nullable, + apple_account_id -> Nullable, created_at -> Timestamp, updated_at -> Timestamp, account -> Text, @@ -151,7 +151,7 @@ diesel::table! { verified_publisher -> Bool, gravatar_email -> Nullable, description -> Nullable, - avatar_hash -> Text, + avatar_hash -> Nullable, created_at -> Timestamp, updated_at -> Timestamp, username -> Text, diff --git a/crates/helm-charts/src/lib.rs b/crates/helm-charts/src/lib.rs index cda222326..b05dcd2e3 100644 --- a/crates/helm-charts/src/lib.rs +++ b/crates/helm-charts/src/lib.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use azalia::remi::{remi::StorageService as _, StorageService}; +use azalia::remi::{core::StorageService as _, StorageService}; use charted_types::{helm::ChartIndex, Ulid, Version}; use eyre::{eyre, Context, Report}; use flate2::bufread::MultiGzDecoder; @@ -161,7 +161,7 @@ pub fn get_chart<'asyncfn, V: AsRef + Send + 'asyncfn>( repo: Ulid, version: V, allow_prereleases: bool, -) -> Pin>> + Send + 'asyncfn>> { +) -> Pin>> + Send + 'asyncfn>> { Box::pin(async move { let version = version.as_ref(); if version == "latest" || version == "current" { @@ -202,7 +202,7 @@ pub fn get_chart_provenance<'asyncfn, V: AsRef + Send + 'asyncfn>( repo: Ulid, version: V, allow_prereleases: bool, -) -> Pin>> + Send + 'asyncfn>> { +) -> Pin>> + Send + 'asyncfn>> { Box::pin(async move { let version = version.as_ref(); if version == "latest" || version == "current" { @@ -351,7 +351,7 @@ mod tests { #[tokio::test] #[cfg_attr(windows, ignore = "fails on windows because it feels like it i guess")] async fn $name() { - use ::azalia::remi::remi::StorageService; + use ::azalia::remi::core::StorageService; let tempdir = ::tempfile::TempDir::new().unwrap(); let path = tempdir.into_path(); @@ -388,7 +388,7 @@ mod tests { let repo = Ulid::new("01J5SG1JAEG4RJCGYC5KJ6QYS2").unwrap(); for version in ["0.1.0-beta", "0.2.1", "1.0.0-beta.1", "2024.3.24", "1.0.0+d1cebae"] { - let request = azalia::remi::remi::UploadRequest::default() + let request = azalia::remi::core::UploadRequest::default() .with_content_type(Some("application/tar+gzip")) .with_data(contents.clone()); @@ -410,7 +410,7 @@ mod tests { let repo = Ulid::new("01J5SG1JAEG4RJCGYC5KJ6QYS2").unwrap(); for version in ["0.1.0-beta", "0.2.1", "1.0.0-beta.1", "2024.3.24", "1.0.0+d1cebae"] { - let request = azalia::remi::remi::UploadRequest::default() + let request = azalia::remi::core::UploadRequest::default() .with_content_type(Some("application/tar+gzip")) .with_data(contents.clone()); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 331ed1d90..166c1f6ec 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -30,6 +30,7 @@ azalia = { workspace = true, features = ["remi", "remi-all"] } async-trait = "0.1.81" axum.workspace = true axum-server = { version = "0.7.1", features = ["tls-rustls"] } +base64 = "0.22.1" charted-authz = { version = "0.1.0", path = "../authz" } charted-core = { version = "0.1.0", path = "../core" } charted-config = { version = "0.1.0", path = "../config" } @@ -37,8 +38,10 @@ charted-database = { version = "0.1.0", path = "../database" } charted-features = { version = "0.1.0", path = "../features" } charted-helm-charts = { version = "0.1.0", path = "../helm-charts" } charted-types = { version = "0.1.0", path = "../types" } +diesel = { workspace = true, features = ["postgres", "sqlite"] } eyre.workspace = true inventory = "0.3.15" +jsonwebtoken = "9.3.0" mime = "0.3.17" multer.workspace = true sentry.workspace = true @@ -49,7 +52,8 @@ serde_path_to_error = "0.1.16" serde_yaml_ng.workspace = true tokio = { workspace = true, features = ["signal", "net"] } tower = "0.5.0" -tower-http = { version = "0.5.2", features = [ +tower-http = { version = "0.6.0", features = [ + "auth", "catch-panic", "compression-gzip", "cors", diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index c5c132334..8872c863e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -13,6 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![feature(never_type)] + mod state; pub use state::*; @@ -23,6 +25,7 @@ pub mod extract; pub mod middleware; pub mod multipart; pub mod openapi; +pub(crate) mod ops; pub mod responses; pub mod routing; diff --git a/crates/server/src/middleware/logging.rs b/crates/server/src/middleware/logging.rs index f67f4faf1..980332567 100644 --- a/crates/server/src/middleware/logging.rs +++ b/crates/server/src/middleware/logging.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::XRequestId; use crate::ServerContext; use axum::{ body::Body, @@ -24,8 +25,6 @@ use axum::{ use std::{sync::atomic::Ordering, time::Instant}; use tracing::{info, instrument}; -use super::XRequestId; - #[derive(FromRequestParts)] pub struct Metadata { extensions: Extensions, diff --git a/crates/server/src/middleware/session/error.rs b/crates/server/src/middleware/session/error.rs index 6399b2128..c20ed72d0 100644 --- a/crates/server/src/middleware/session/error.rs +++ b/crates/server/src/middleware/session/error.rs @@ -12,3 +12,192 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +use crate::ServerContext; +use axum::{http::StatusCode, response::IntoResponse}; +use charted_core::api; +use std::{borrow::Cow, fmt::Display}; + +#[derive(Debug)] +pub enum Error { + /// An unknown authentication type was received. + UnknownAuthenticationType(Cow<'static, str>), + + /// An error occurred while decoding base64 data from user input. + DecodeBase64(base64::DecodeError), + + /// Generic message that will be put into the `message` field of an API error. + Message(Cow<'static, str>), + + /// Something in the database failed. + Database(diesel::result::Error), + + /// Failed to decode from a ULID. + DecodeUlid(charted_types::ulid::DecodeError), + + /// An error occured during JWT validation. + Jwt(jsonwebtoken::errors::Error), + + /// An unknown error that hasn't been handled yet. It is most likely + /// wrapped in a [`eyre::Report`]. + Unknown(eyre::Report), + + /// The request missed the `Authorization` HTTP header. + MissingAuthorizationHeader, + + /// The token received was not the correct refresh token. + RefreshTokenRequired, + + /// The password given from the basic authentication scheme was invalid. + InvalidPassword, + + /// Session queried was unknown to the server. + UnknownSession, +} + +impl Error { + pub(crate) fn invalid_utf8() -> Self { + Error::msg("received invalid utf-8 content") + } + + pub(crate) fn msg>>(msg: I) -> Self { + Error::Message(msg.into()) + } + + pub fn status_code(&self) -> StatusCode { + use Error as E; + + match self { + E::MissingAuthorizationHeader + | E::UnknownAuthenticationType(_) + | E::Message(_) + | E::DecodeBase64(_) + | E::RefreshTokenRequired + | E::DecodeUlid(_) => StatusCode::NOT_ACCEPTABLE, + + E::InvalidPassword => StatusCode::UNAUTHORIZED, + E::UnknownSession => StatusCode::NOT_FOUND, + E::Database(_) | E::Unknown(_) => StatusCode::INTERNAL_SERVER_ERROR, + E::Jwt(err) => match err.kind() { + jsonwebtoken::errors::ErrorKind::InvalidToken => StatusCode::FORBIDDEN, + jsonwebtoken::errors::ErrorKind::ExpiredSignature => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + } + } + + fn api_error_code(&self) -> api::ErrorCode { + use api::ErrorCode::*; + use Error as E; + + match self { + E::RefreshTokenRequired => RefreshTokenRequired, + E::InvalidPassword => InvalidPassword, + E::UnknownSession => UnknownSession, + E::MissingAuthorizationHeader => MissingAuthorizationHeader, + E::Message(_) => InvalidAuthorizationParts, + E::UnknownAuthenticationType(_) => InvalidAuthenticationType, + E::DecodeBase64(_) => UnableToDecodeBase64, + E::DecodeUlid(_) => UnableToDecodeUlid, + E::Database(_) | E::Unknown(_) => InternalServerError, + E::Jwt(err) => match err.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => SessionExpired, + jsonwebtoken::errors::ErrorKind::InvalidToken => InvalidSessionToken, + _ => InternalServerError, + }, + } + } +} + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + >::from(self).into_response() + } +} + +impl From for api::Response { + fn from(value: Error) -> Self { + api::err(value.status_code(), (value.api_error_code(), value.to_string())) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use Error as E; + + match self { + E::UnknownAuthenticationType(ty) => { + let cx = ServerContext::get(); + + #[allow(clippy::obfuscated_if_else)] + let schemes = cx + .config + .sessions + .enable_basic_auth + .then_some("[Bearer, Basic, ApiKey]") + .unwrap_or("[Bearer, ApiKey]"); + + write!( + f, + "received invalid authorization type [{ty}]: expected oneof {schemes}" + ) + } + + E::MissingAuthorizationHeader => f.write_str("missing `Authorization` header from request"), + E::RefreshTokenRequired => f.write_str("endpoint expected a valid refresh token"), + E::InvalidPassword => f.write_str("received invalid password"), + E::UnknownSession => f.write_str("unknown session"), + E::Message(msg) => f.write_str(msg), + E::DecodeBase64(err) => Display::fmt(err, f), + E::DecodeUlid(err) => Display::fmt(err, f), + E::Database(_) => f.write_str("database error: please report this if this is a common occurrence"), + E::Unknown(e) => write!( + f, + "unknown error occurred, report this if this is a common occurrence: {e}" + ), + + E::Jwt(err) => Display::fmt(err, f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::DecodeBase64(err) => Some(err), + Error::Jwt(err) => Some(err), + + _ => None, + } + } +} + +impl From for Error { + fn from(value: base64::DecodeError) -> Self { + Self::DecodeBase64(value) + } +} + +impl From for Error { + fn from(value: jsonwebtoken::errors::Error) -> Self { + Self::Jwt(value) + } +} + +impl From for Error { + fn from(value: diesel::result::Error) -> Self { + Self::Database(value) + } +} + +impl From for Error { + fn from(value: charted_types::ulid::DecodeError) -> Self { + Self::DecodeUlid(value) + } +} + +impl From for Error { + fn from(value: eyre::Report) -> Self { + Self::Unknown(value) + } +} diff --git a/crates/server/src/middleware/session/extract.rs b/crates/server/src/middleware/session/extract.rs new file mode 100644 index 000000000..3d5b4d785 --- /dev/null +++ b/crates/server/src/middleware/session/extract.rs @@ -0,0 +1,26 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use charted_types::User; + +/// `Session` is a Axum extractor avaliable when a route has its session middleware configured. +#[derive(Debug, Clone)] +pub struct Session { + /// Session data from Redis, if this is `Bearer`. Otherwise, `None` is returned. + pub session: Option, + + /// User that is executing this route; always avaliable + pub user: User, +} diff --git a/crates/server/src/middleware/session/mod.rs b/crates/server/src/middleware/session/mod.rs index 6399b2128..bd2275dea 100644 --- a/crates/server/src/middleware/session/mod.rs +++ b/crates/server/src/middleware/session/mod.rs @@ -12,3 +12,451 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +mod error; +pub use error::*; + +mod extract; +pub use extract::*; +use tower_http::auth::AsyncAuthorizeRequest; + +use crate::{ops, ServerContext}; +use axum::{ + body::Body, + extract::Request, + http::{header::AUTHORIZATION, Response, StatusCode}, + response::IntoResponse, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use charted_authz::InvalidPassword; +use charted_core::{ + api::{self, internal_server_error}, + bitflags::{ApiKeyScope, ApiKeyScopes}, + BoxedFuture, +}; +use charted_database::{ + connection, + schema::{postgresql, sqlite}, +}; +use charted_types::{name::Name, Ulid}; +use diesel::sqlite::Sqlite; +use jsonwebtoken::{DecodingKey, Validation}; +use serde_json::{json, Value}; +use std::{borrow::Cow, collections::HashMap, str::FromStr}; +use tracing::{error, instrument, trace}; + +pub const JWT_ISS: &str = "Noelware"; +pub const JWT_AUS: &str = "charted-server"; + +#[derive(Clone, Default)] +pub struct Middleware { + allow_unauthorized_requests: bool, + refresh_token_required: bool, + scopes: ApiKeyScopes, +} + +impl Middleware { + pub fn allow_unauthorized_requests(self, yes: bool) -> Self { + Self { + allow_unauthorized_requests: yes, + ..self + } + } + + pub fn refresh_token_required(self, yes: bool) -> Self { + Self { + refresh_token_required: yes, + ..self + } + } + + pub fn scopes>(self, scopes: I) -> Self { + let mut bitfield = self.scopes; + bitfield.add(scopes); + + Self { + scopes: bitfield, + ..self + } + } +} + +impl Middleware { + /// Performs basic authentication if the server has enabled it. + #[instrument(name = "charted.server.authz.basic", skip_all)] + async fn basic_auth( + self, + mut req: Request, + ctx: &ServerContext, + token: String, + ) -> Result, Response> { + if self.refresh_token_required { + return Err(api::err( + StatusCode::NOT_ACCEPTABLE, + ( + api::ErrorCode::RefreshTokenRequired, + "cannot use basic authentication scheme on this route", + ), + ) + .into_response()); + } + + let decoded = String::from_utf8( + STANDARD + .decode(&token) + .inspect_err(|e| { + error!(error = %e, "failed to decode base64 from authorization header"); + sentry::capture_error(e); + }) + .map_err(Error::DecodeBase64) + .map_err(IntoResponse::into_response)?, + ) + .map_err(|_| Error::invalid_utf8()) + .map_err(IntoResponse::into_response)?; + + let (username, password) = match decoded.split_once(':') { + Some((_, pass)) if pass.contains(':') => { + let idx = pass.chars().position(|c| c == ':').unwrap_or_default(); + return Err(Error::msg(format!("received more than one ':' @ pos {idx}")).into_response()); + } + + Some(tuple) => tuple, + None => { + return Err( + Error::msg("basic authentication requires the syntax of 'username:password'").into_response(), + ) + } + }; + + let user = username + .parse::() + .map_err(|e| Error::msg(e.to_string()).into_response())?; + + let Some(user) = ops::db::user::get(ctx, user.clone()) + .await + .map_err(Error::Unknown) + .map_err(IntoResponse::into_response)? + else { + return Err(api::err( + StatusCode::NOT_FOUND, + ( + api::ErrorCode::EntityNotFound, + "user with id doesn't exist", + json!({"username":user}), + ), + ) + .into_response()); + }; + + match ctx.authz.authenticate(&user, password.to_owned()).await { + Ok(()) => { + req.extensions_mut().insert(extract::Session { session: None, user }); + Ok(req) + } + + Err(e) => { + if e.downcast_ref::().is_some() { + return Err(Error::InvalidPassword.into_response()); + } + + error!(error = %e, "failed to authenticate from authz backend"); + sentry::capture_error(&*e); + + Err(internal_server_error().into_response()) + } + } + } + + /// Performs JWT-based authentication for `Bearer` tokens. + #[instrument(name = "charted.server.authz.bearer", skip_all)] + async fn bearer_auth( + self, + mut req: Request, + ctx: &ServerContext, + token: String, + ) -> Result, Response> { + let key = DecodingKey::from_secret(ctx.config.jwt_secret_key.as_ref()); + let decoded = jsonwebtoken::decode::>( + &token, + &key, + &Validation::new(jsonwebtoken::Algorithm::HS512), + ) + .inspect_err(|e| { + error!(error = %e, "failed to decode JWT token"); + sentry::capture_error(e); + }) + .map_err(Error::Jwt) + .map_err(IntoResponse::into_response)?; + + // All JWT tokens created by the server will always have a `user_id` which + // will always be a valid ULID. + let uid = decoded + .claims + .get("user_id") + .filter(|x| matches!(x, Value::String(_))) + .and_then(Value::as_str) + .map(Ulid::new) + .ok_or_else(|| Error::msg("missing `user_id` JWT claim").into_response())? + .map_err(Error::DecodeUlid) + .map_err(IntoResponse::into_response)?; + + let session = decoded + .claims + .get("session_id") + .filter(|x| matches!(x, Value::String(_))) + .and_then(Value::as_str) + .map(Ulid::new) + .ok_or_else(|| Error::msg("missing `session_id` JWT claim").into_response())? + .map_err(Error::DecodeUlid) + .map_err(IntoResponse::into_response)?; + + let mut conn = ctx + .pool + .get() + .inspect_err(|e| { + error!(error = %e, "failed to get database connection"); + sentry::capture_error(e); + }) + .map_err(|_| api::internal_server_error().into_response())?; + + let session = connection!(@raw conn { + PostgreSQL(conn) => conn.build_transaction().read_only().run::(|txn| { + use postgresql::sessions::{dsl::*, table}; + use diesel::pg::Pg; + + table + .select(>::as_select()) + .filter(owner.eq(uid)) + .filter(id.eq(&session)) + .first(txn) + .map_err(Into::into) + }); + + SQLite(conn) => conn.immediate_transaction(|txn| { + use sqlite::sessions::{dsl::*, table}; + + table + .select(>::as_select()) + .filter(owner.eq(uid)) + .filter(id.eq(&session)) + .first(txn) + .map_err(Into::into) + }); + }) + .map_err(|e| match e { + Error::Database(diesel::result::Error::NotFound) => api::err( + StatusCode::NOT_FOUND, + ( + api::ErrorCode::EntityNotFound, + "session with id doesn't exist", + json!({"session":session}), + ), + ) + .into_response(), + + err => err.into_response(), + })?; + + if session.owner != uid { + error!("FATAL: assertion of `session.owner` == {uid} failed"); + return Err(Error::UnknownSession.into_response()); + } + + if self.refresh_token_required && session.refresh_token != token { + return Err(Error::RefreshTokenRequired.into_response()); + } + + let Some(user) = ops::db::user::get(ctx, uid) + .await + .map_err(Error::Unknown) + .map_err(IntoResponse::into_response)? + else { + return Err(api::err( + StatusCode::NOT_FOUND, + ( + api::ErrorCode::EntityNotFound, + "user with id doesn't exist", + json!({"user":uid}), + ), + ) + .into_response()); + }; + + req.extensions_mut().insert(Session { + session: Some(session), + user, + }); + + Ok(req) + } + + /// Performs API Key-based authentication + #[instrument(name = "charted.server.authz.apikey", skip_all)] + async fn apikey_auth( + self, + mut req: Request, + ctx: &ServerContext, + token: String, + ) -> Result, Response> { + if self.refresh_token_required { + return Err(api::err( + StatusCode::NOT_ACCEPTABLE, + ( + api::ErrorCode::RefreshTokenRequired, + "cannot use api key authentication on a bearer-only route", + ), + ) + .into_response()); + } + + let Some(apikey) = ops::db::apikey::get(ctx, token, None::) + .await + .map_err(Error::Unknown) + .map_err(IntoResponse::into_response)? + else { + return Err(api::err( + StatusCode::NOT_FOUND, + ( + api::ErrorCode::EntityNotFound, + "api key with received token was not found", + ), + ) + .into_response()); + }; + + let scopes = apikey.bitfield(); + for (scope, bit) in self.scopes.flags() { + trace!(%apikey.name, "checking if api key has scope [{scope}] enabled"); + if !scopes.contains(bit) { + trace!(%apikey.name, %scope, "api key scope is not enabled"); + return Err(api::err( + StatusCode::FORBIDDEN, + ( + api::ErrorCode::AccessNotPermitted, + "api key doesn't have access to this route due to not enabling the required flag", + json!({"scope":scope,"$repr":bit}), + ), + ) + .into_response()); + } + } + + let Some(user) = ops::db::user::get(ctx, apikey.owner) + .await + .map_err(Error::Unknown) + .map_err(IntoResponse::into_response)? + else { + return Err(api::err( + StatusCode::NOT_FOUND, + ( + api::ErrorCode::EntityNotFound, + "user with id doesn't exist", + json!({"user":apikey.owner}), + ), + ) + .into_response()); + }; + + req.extensions_mut().insert(extract::Session { session: None, user }); + Ok(req) + } +} + +impl AsyncAuthorizeRequest for Middleware { + type ResponseBody = Body; + type RequestBody = Body; + type Future = BoxedFuture<'static, Result, Response>>; + + fn authorize(&mut self, request: axum::http::Request) -> Self::Future { + let ctx = ServerContext::get(); + let headers = request.headers(); + + let Some(header) = headers.get(AUTHORIZATION) else { + if self.allow_unauthorized_requests { + return Box::pin(noop(request)); + } + + return Box::pin(error(Error::MissingAuthorizationHeader)); + }; + + let Ok(value) = String::from_utf8(header.as_ref().to_vec()).inspect_err(|err| { + error!(error = %err, "failed to validate UTF-8 contents in header"); + sentry::capture_error(err); + }) else { + return Box::pin(error(Error::invalid_utf8())); + }; + + let (ty, value) = match value.split_once(' ') { + Some((_, value)) if value.contains(' ') => { + let space = value.chars().position(|x| x == ' ').unwrap_or_default(); + return Box::pin(error(Error::msg(format!( + "received extra space at {space} when parsing header" + )))); + } + + Some((ty, value)) => match ty.parse::() { + Ok(ty) => (ty, value), + Err(e) => return Box::pin(error(Error::UnknownAuthenticationType(Cow::Owned(e)))), + }, + + None => { + return Box::pin(error(Error::msg( + "auth header must be in the form of 'Type Value', i.e, 'ApiKey hjdjshdjs'", + ))) + } + }; + + match ty { + AuthType::Bearer => Box::pin(self.clone().bearer_auth(request, ctx, value.to_owned())), + AuthType::ApiKey => Box::pin(self.clone().apikey_auth(request, ctx, value.to_owned())), + AuthType::Basic => Box::pin(self.clone().basic_auth(request, ctx, value.to_owned())), + } + } +} + +async fn noop(request: Request) -> Result, Response> { + Ok(request) +} + +async fn error(error: Error) -> Result, Response> { + Err(error.into_response()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum AuthType { + /// `Bearer` authentication type, typically a JWT token that was *possibly* + /// created by the server. + Bearer, + + /// `ApiKey` authentication type, created from the API keys API. + ApiKey, + + /// `Basic` authentication type, if enabled; allows to send HTTP requests + /// with basic credentials (`user:pass` in b64). + Basic, +} + +impl AuthType { + /// Returns a slice of the avaliable [`AuthType`]s. If `basic` is false, then [`AuthType::Basic`] + /// will not be avaliable. + pub const fn values(basic: bool) -> &'static [AuthType] { + if basic { + &[AuthType::ApiKey, AuthType::Basic, AuthType::Bearer] + } else { + &[AuthType::ApiKey, AuthType::Bearer] + } + } +} + +impl FromStr for AuthType { + type Err = String; + + fn from_str(s: &str) -> Result { + match &*s.to_ascii_lowercase() { + "apikey" => Ok(Self::ApiKey), + "bearer" => Ok(Self::Bearer), + "basic" => Ok(Self::Basic), + _ => Err(s.to_owned()), + } + } +} diff --git a/crates/server/src/ops/db/apikey.rs b/crates/server/src/ops/db/apikey.rs new file mode 100644 index 000000000..9e413a459 --- /dev/null +++ b/crates/server/src/ops/db/apikey.rs @@ -0,0 +1,73 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{NameOrUlid, ServerContext}; +use charted_database::{ + connection, + schema::{postgresql, sqlite}, +}; +use charted_types::ApiKey; +use eyre::Report; +use tracing::instrument; + +#[instrument(name = "charted.server.ops.db.getApiKey", skip_all)] +pub async fn get( + ctx: &ServerContext, + key: String, + owner: Option>, +) -> eyre::Result> { + let owner_uid: Option = owner.map(Into::into); + let mut conn = ctx.pool.get()?; + + connection!(@raw conn { + PostgreSQL(conn) => conn.build_transaction().read_only().run::<_, eyre::Report, _>(|txn| { + use postgresql::api_keys::{dsl::*, table}; + use diesel::pg::Pg; + + let mut query = table.into_boxed().select(>::as_select()).filter(token.eq(key)); + if let Some(uid) = owner_uid { + query = match uid { + NameOrUlid::Ulid(uid) => query.filter(owner.eq(uid)), + NameOrUlid::Name(user_name) => query.filter(owner.eq(user_name)), + }; + } + + match query.first(txn) { + Ok(user) => Ok(Some(user)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(Report::from(e)) + } + }); + + SQLite(conn) => conn.immediate_transaction(|txn| { + use sqlite::api_keys::{dsl::*, table}; + use diesel::sqlite::Sqlite; + + let mut query = table.into_boxed().select(>::as_select()).filter(token.eq(key)); + if let Some(uid) = owner_uid { + query = match uid { + NameOrUlid::Ulid(uid) => query.filter(owner.eq(uid)), + NameOrUlid::Name(user_name) => query.filter(owner.eq(user_name)), + }; + } + + match query.first(txn) { + Ok(user) => Ok(Some(user)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(Report::from(e)) + } + }); + }) +} diff --git a/crates/server/src/ops/db/mod.rs b/crates/server/src/ops/db/mod.rs new file mode 100644 index 000000000..56178f118 --- /dev/null +++ b/crates/server/src/ops/db/mod.rs @@ -0,0 +1,19 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Contains the database operations for most entities so it doesn't get repeated as much. + +pub mod apikey; +pub mod user; diff --git a/crates/server/src/ops/db/user.rs b/crates/server/src/ops/db/user.rs new file mode 100644 index 000000000..ed062b209 --- /dev/null +++ b/crates/server/src/ops/db/user.rs @@ -0,0 +1,73 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{NameOrUlid, ServerContext}; +use charted_database::{ + connection, + schema::{postgresql, sqlite}, +}; +use charted_types::User; +use eyre::Report; +use tracing::instrument; + +#[instrument(name = "charted.server.ops.db.getUser", skip_all)] +pub async fn get>(ctx: &ServerContext, id: ID) -> eyre::Result> { + let name_or_ulid = id.into(); + let mut conn = ctx.pool.get()?; + + connection!(@raw conn { + PostgreSQL(conn) => conn.build_transaction().read_only().run::<_, eyre::Report, _>(|txn| { + use postgresql::users::{dsl::*, table}; + use diesel::pg::Pg; + + // We have to box the query since we need to match over either a + // ULID or Username and we can't do that if it isn't boxed. + let mut query = table + .into_boxed() + .select(>::as_select()); + + query = match name_or_ulid { + NameOrUlid::Ulid(uid) => query.filter(id.eq(uid)), + NameOrUlid::Name(user_name) => query.filter(username.eq(user_name)), + }; + + match query.first(txn) { + Ok(user) => Ok(Some(user)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(Report::from(e)) + } + }); + + SQLite(conn) => conn.immediate_transaction(|txn| { + use sqlite::users::{dsl::*, table}; + use diesel::sqlite::Sqlite; + + let mut query = table + .into_boxed() + .select(>::as_select()); + + query = match name_or_ulid { + NameOrUlid::Ulid(uid) => query.filter(id.eq(uid)), + NameOrUlid::Name(user_name) => query.filter(username.eq(user_name)), + }; + + match query.first(txn) { + Ok(user) => Ok(Some(user)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(Report::from(e)) + } + }); + }) +} diff --git a/crates/server/src/ops/mod.rs b/crates/server/src/ops/mod.rs new file mode 100644 index 000000000..7308ed31b --- /dev/null +++ b/crates/server/src/ops/mod.rs @@ -0,0 +1,18 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The `charted_server::ops` module contains all the operations for most routes. + +pub mod db; diff --git a/crates/server/src/types.rs b/crates/server/src/types.rs index ff22ab97f..3b8d1f528 100644 --- a/crates/server/src/types.rs +++ b/crates/server/src/types.rs @@ -13,6 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::fmt::Display; + use charted_types::{name::Name, Ulid}; use serde::Deserialize; @@ -31,6 +33,15 @@ pub enum NameOrUlid { Name(charted_types::name::Name), } +impl Display for NameOrUlid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ulid(ulid) => Display::fmt(ulid, f), + Self::Name(name) => Display::fmt(name, f), + } + } +} + impl NameOrUlid { /// Returns [`Some`]\([`Name`]\) that was referenced, otherwise `None` is returned /// if this is a ULID instance. @@ -63,6 +74,12 @@ impl From for NameOrUlid { } } +impl From for NameOrUlid { + fn from(_: !) -> Self { + unimplemented!() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tatsuki/Cargo.toml b/crates/tatsuki/Cargo.toml new file mode 100644 index 000000000..433c4e3d2 --- /dev/null +++ b/crates/tatsuki/Cargo.toml @@ -0,0 +1,54 @@ +# 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +# Copyright 2024 Noel Towa +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "tatsuki" +description = "🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic" +version = "0.1.0" +authors = ["Noel Towa "] +categories = ["asynchronous"] +edition = "2021" +license = "MIT" +repository = "https://github.com/auguwu/tatsuki" +rust-version = "1.76" + +[features] +default = ["cron", "tokio"] + +tracing = ["dep:tracing"] +tokio = ["tokio/rt", "tokio/time"] +serde = ["dep:serde"] +cron = ["dep:cron"] +log = ["dep:log"] + +[dependencies] +chrono.workspace = true +cron = { version = "0.12.1", optional = true } +log = { version = "0.4.22", optional = true } +pin-project-lite = "0.2.14" +serde = { workspace = true, optional = true } +tokio.workspace = true +tokio-util = "0.7.12" +tracing = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(noeldoc)'] } diff --git a/crates/tatsuki/README.md b/crates/tatsuki/README.md new file mode 100644 index 000000000..8f17a46a9 --- /dev/null +++ b/crates/tatsuki/README.md @@ -0,0 +1,125 @@ +# 🐻‍❄️🗻 `tatsuki` +**Tatsuki** is a dead simple asynchronous-based job scheduling library for Rust applications which can support multiple kinds of jobs that can be processed in the background. + +**Tatsuki** was built to have a small, robust library that can process background jobs without any persistence. I didn't want to use [`tokio-cron-scheduler`](https://docs.rs/tokio-cron-scheduler) due to it being very heavy and I didn't want to require configuring jobs from a database. + +## Crate Features +| Name | Description | Enabled by default? | +| :--- | :---------- | :------------------ | +| `tracing` | Allows the library to emit **tracing** records to better understand what Tatsuki is doing | No. | +| `chrono` | Allows the use of the [`chrono`] library to keep track of jobs instead of using the standard library. | Yes (by the `cron` feature) | +| `tokio` | Enables the Tokio runtime for calculating if a job should be ran or cancelled alltogether. | Yes. | +| `cron` | Enables the use of cron jobs via the [`cron`] crate. | Yes. | +| `log` | Same as the `tracing` feature but uses [`log`] instead. | No. | + +## Example +```rust,no_run +// [dependencies] +// tatsuki = "0.1" +// tokio = { version = "*", features = ["full"] } + +use tatsuki::Scheduler; +``` + + diff --git a/crates/tatsuki/macros/Cargo.toml b/crates/tatsuki/macros/Cargo.toml new file mode 100644 index 000000000..e62eca28a --- /dev/null +++ b/crates/tatsuki/macros/Cargo.toml @@ -0,0 +1,33 @@ +# 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +# Copyright 2024 Noel Towa +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "tatsuki-macros" +description = "🐻‍❄️🗻 Internal procedural macros to allow using functions as jobs." +version = "0.1.0" +authors = ["Noel Towa "] +categories = ["asynchronous"] +edition = "2021" +license = "MIT" +repository = "https://github.com/auguwu/tatsuki" +rust-version = "1.76" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.86" +quote = "1.0.37" +syn = "2.0.77" diff --git a/crates/tatsuki/macros/src/lib.rs b/crates/tatsuki/macros/src/lib.rs new file mode 100644 index 000000000..0ab33e7d6 --- /dev/null +++ b/crates/tatsuki/macros/src/lib.rs @@ -0,0 +1,14 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/crates/tatsuki/src/clock.rs b/crates/tatsuki/src/clock.rs new file mode 100644 index 000000000..0e6669e60 --- /dev/null +++ b/crates/tatsuki/src/clock.rs @@ -0,0 +1,61 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use chrono::{DateTime, TimeZone}; + +/// A clock to determine the current time for a job. +pub trait Clock: Send { + /// Determines the current time based off a given timezone. + fn now(&self, tz: &Tz) -> DateTime; +} + +/// A [`Clock`] implementation that uses chrono's [`Local::now`] function to determine +/// the current time. +#[derive(Clone, Default)] +pub struct ChronoClock; + +impl Clock for ChronoClock { + fn now(&self, tz: &Tz) -> DateTime { + chrono::Local::now().with_timezone(tz) + } +} + +/// A clock meant for testing the job scheduler. +#[derive(Clone)] +pub struct TestClock(DateTime); +impl TestClock { + /// Creates a new [`TestClock`]. + /// + /// ## Example + /// ``` + /// # use tatsuki::{tokio, Scheduler, rt::Tokio, TestClock}; + /// # use chrono::DateTime; + /// # + /// let scheduler: Scheduler = tokio() + /// .with_clock(TestClock::new(DateTime::MIN_UTC)); + /// ``` + pub fn new>>(dt: DT) -> TestClock { + TestClock(dt.into()) + } +} + +impl Clock for TestClock +where + Tz::Offset: Send, +{ + fn now(&self, tz: &Tz2) -> DateTime { + self.0.with_timezone(tz) + } +} diff --git a/crates/tatsuki/src/job/cron.rs b/crates/tatsuki/src/job/cron.rs new file mode 100644 index 000000000..0ab33e7d6 --- /dev/null +++ b/crates/tatsuki/src/job/cron.rs @@ -0,0 +1,14 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/crates/tatsuki/src/job/interval.rs b/crates/tatsuki/src/job/interval.rs new file mode 100644 index 000000000..864075794 --- /dev/null +++ b/crates/tatsuki/src/job/interval.rs @@ -0,0 +1,120 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::Job; +use std::{borrow::Cow, error::Error, future::Future, marker::PhantomData, time::Duration}; + +/// A `Job` that will run on each interval (i.e, 30s). +pub struct IntervalBasedJob< + 'fut, + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync + 'fut, +> { + __lt_marker: PhantomData<&'fut ()>, + + interval: Duration, + name: Cow<'static, str>, + f: F, +} + +impl<'fut, F, Fut> IntervalBasedJob<'fut, F, Fut> +where + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync + 'fut, +{ + /// Creates a new [`IntervalBasedJob`] with a given function and duration. + pub fn new(f: F, duration: Duration) -> Self { + let name = std::any::type_name::(); + + IntervalBasedJob { + __lt_marker: PhantomData, + interval: duration, + name: Cow::Borrowed(name), + f, + } + } + + /// Overwrites the job name. + /// + /// By default, it'll use the [`type_name`][std::any::type_name] function from the + /// standard library to determine the name of the job. + pub fn with_name>>(self, name: S) -> Self { + Self { + name: name.into(), + + ..self + } + } +} + +impl<'fut, F, Fut> Job for IntervalBasedJob<'fut, F, Fut> +where + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync + 'fut, +{ + fn name(&self) -> Cow<'static, str> { + self.name.clone() + } + + fn can_be_executed(&self) -> bool { + false + } + + fn run(&mut self) -> crate::BoxedFuture>> { + Box::pin((self.f)()) + } +} + +impl<'fut, F, Fut> From<(F, Duration)> for IntervalBasedJob<'fut, F, Fut> +where + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync + 'fut, +{ + fn from((f, duration): (F, Duration)) -> Self { + IntervalBasedJob::new(f, duration) + } +} + +#[cfg(test)] +#[test] +fn assert_send_sync() { + fn __assert_send(_: &S) {} + fn __assert_sync(_: &S) {} + + async fn weow() -> Result<(), Box> { + Ok(()) + } + + let job = IntervalBasedJob::new(weow, Duration::from_secs(30)); + + __assert_send(&job); + __assert_sync(&job); +} + +#[cfg(test)] +#[test] +fn assert_any_fn_can_work() { + async fn weow() -> Result<(), Box> { + Ok(()) + } + + let _ = IntervalBasedJob { + __lt_marker: PhantomData, + + interval: Duration::from_secs(30), + name: Cow::Borrowed("hello world"), + f: weow, + }; +} diff --git a/crates/tatsuki/src/job/mod.rs b/crates/tatsuki/src/job/mod.rs new file mode 100644 index 000000000..d33a85abe --- /dev/null +++ b/crates/tatsuki/src/job/mod.rs @@ -0,0 +1,43 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::BoxedFuture; +use std::{borrow::Cow, error::Error}; + +#[cfg(feature = "cron")] +mod cron; + +#[cfg(feature = "cron")] +pub use cron::*; + +mod interval; +pub use interval::*; + +mod oneshot; +pub use oneshot::*; + +mod schedule; +pub use schedule::*; + +pub trait Job: Send + Sync { + /// Returns the name of this job. + fn name(&self) -> Cow<'static, str>; + + /// Checks whenever if we can be executed. + fn can_be_executed(&self) -> bool; + + /// Runs the actual job. + fn run(&mut self) -> BoxedFuture>>; +} diff --git a/crates/tatsuki/src/job/oneshot.rs b/crates/tatsuki/src/job/oneshot.rs new file mode 100644 index 000000000..87112fa80 --- /dev/null +++ b/crates/tatsuki/src/job/oneshot.rs @@ -0,0 +1,43 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{borrow::Cow, error::Error, future::Future, marker::PhantomData}; + +pub struct OneshotJob< + 'fut, + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync, +> { + __lt_marker: PhantomData<&'fut ()>, + + executed: bool, + name: Cow<'static, str>, + f: F, +} + +/* +/// A `Job` that will run on each interval (i.e, 30s). +pub struct IntervalBasedJob< + 'fut, + F: FnOnce() -> Fut + Copy + Send + Sync, + Fut: Future>> + Send + Sync + 'fut, +> { + __lt_marker: PhantomData<&'fut ()>, + + interval: Duration, + name: Cow<'static, str>, + f: F, +} +*/ diff --git a/crates/tatsuki/src/job/schedule.rs b/crates/tatsuki/src/job/schedule.rs new file mode 100644 index 000000000..0ab33e7d6 --- /dev/null +++ b/crates/tatsuki/src/job/schedule.rs @@ -0,0 +1,14 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/crates/tatsuki/src/lib.rs b/crates/tatsuki/src/lib.rs new file mode 100644 index 000000000..45291bee6 --- /dev/null +++ b/crates/tatsuki/src/lib.rs @@ -0,0 +1,198 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![doc(html_logo_url = "https://cdn.floofy.dev/images/August.png")] +#![doc = include_str!("../README.md")] +#![cfg_attr(any(docsrs, noeldoc), feature(doc_cfg))] +#![allow(rustdoc::broken_intra_doc_links)] // we use GitHub's alerts and rustdoc doesn't like them +#![allow(unused)] + +use std::{any::Any, fmt::Debug, future::Future, pin::Pin, sync::Arc, time::Duration}; +use tokio_util::sync::CancellationToken; + +pub mod job; +pub mod rt; + +mod clock; +pub use clock::*; + +/// Type-alias that represents a boxed future. +pub type BoxedFuture<'a, Output> = + ::core::pin::Pin<::std::boxed::Box + Send + 'a>>; + +pub struct Scheduler { + cancellation_token: CancellationToken, + runtime: R, + clock: C, + jobs: Vec>, +} + +impl Debug for Scheduler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Scheduler") + .field("cancelled", &self.cancellation_token.is_cancelled()) + .field("runtime", &self.runtime) + .field("clock", &self.clock) + .field("jobs", &self.jobs.len()) + .finish() + } +} + +/// Warning: cloning a [`Scheduler`] will create a sibling cancellation token, and if `.cancel` was called +/// from a parent cancellation token, the event loop will no longer trigger +impl Clone for Scheduler { + fn clone(&self) -> Self { + Self { + cancellation_token: self.cancellation_token.clone(), + runtime: self.runtime.clone(), + clock: self.clock.clone(), + jobs: self.jobs.clone(), + } + } +} + +impl Default for Scheduler { + fn default() -> Self { + Self { + cancellation_token: CancellationToken::default(), + runtime: R::default(), + clock: C::default(), + jobs: Vec::new(), + } + } +} + +impl Scheduler { + /// Creates a new, empty [`Scheduler`]. + /// + /// As this will require defining a runtime, you can use the `tokio` method + /// in the crate to use the defined runtimes instead. + pub fn new(runtime: R, clock: C) -> Scheduler { + Scheduler { + cancellation_token: CancellationToken::default(), + runtime, + clock, + jobs: Vec::new(), + } + } + + /// Cancels the [`Scheduler`] from being scheduled onto the runtime. + /// + /// This will kill all jobs that are waiting to be processed and will wait + /// for jobs that are running to be killed. + /// + /// NOTE: Calling [`Scheduler::cancel`] is not an atomic operation! Read + /// the [`CancellationToken::cancel`] documentation for more information. + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + /// Returns `true` if the [`Scheduler`] had been cancelled. + pub fn is_cancelled(&self) -> bool { + self.cancellation_token.is_cancelled() + } + + /// Replace the clock from this scheduler with a new clock. + pub fn with_clock(self, clock: C2) -> Scheduler { + Scheduler { + cancellation_token: self.cancellation_token, + runtime: self.runtime, + clock, + jobs: self.jobs, + } + } + + /// Process a single job tick. This will process all jobs once. + pub async fn tick(&mut self) { + // // Even if we were cancelled, `tick` can be called in userland code. + // if self.is_cancelled() { + // return; + // } + + #[cfg(feature = "tracing")] + ::tracing::trace!( + "we have {} scheduled jobs, determining which ones are going to be executed...", + me.jobs.len() + ); + + #[cfg(feature = "log")] + ::log::trace!( + "we have {} scheduled jobs, determining which ones are going to be executed...", + me.jobs.len() + ); + } + + async fn run_pending(&mut self) {} +} + +impl Scheduler { + /// Emit a new future that will be spawned in the background to process call job ticks + /// per 500ms to determine what jobs are avaliable to be scheduled or need to be pushed + /// back. + pub fn schedule_in_background(&self) { + let mut me = self.clone(); + self.runtime.spawn(async move { + #[cfg(feature = "tracing")] + ::tracing::trace!( + "scheduler was told to be ran in the background for {} jobs", + me.jobs.len() + ); + + #[cfg(feature = "log")] + ::log::trace!( + "scheduler was told to be ran in the background for {} jobs", + me.jobs.len() + ); + + // Well, we should schedule all jobs for now + me.tick().await; + + loop { + tokio::select! { + // If we receive a cancellation, then we need to + // break out of the loop. + _ = me.cancellation_token.cancelled() => { + break; + } + + // Otherwise, if we can sleep for ~500ms, then + // we can process another job tick. + _ = me.runtime.sleep(Duration::from_millis(500)) => { + me.tick().await; + } + } + } + + #[cfg(feature = "tracing")] + ::tracing::trace!("scheduler cancelled its execution"); + + #[cfg(feature = "log")] + ::log::trace!("scheduler cancelled its execution"); + }); + } +} + +#[cfg(feature = "tokio")] +#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))] +/// Creates a new [`Scheduler`] using the [`rt::tokio::Tokio`] runtime. +pub fn tokio() -> Scheduler { + Scheduler::default() +} + +pub type JobFuture<'a> = Box>> + Send + Sync + 'a>; + +struct ScheduledJobs<'a> { + futures: Vec>>>, +} diff --git a/crates/tatsuki/src/rt.rs b/crates/tatsuki/src/rt.rs new file mode 100644 index 000000000..f6f688060 --- /dev/null +++ b/crates/tatsuki/src/rt.rs @@ -0,0 +1,40 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Implements types and implementations for different asynchronous runtimes. +//! +//! * [`tokio`] is fully supported. +//! +//! [`tokio`]: https://tokio.rs + +use std::{future::Future, pin::Pin, time::Duration}; + +#[cfg(feature = "tokio")] +#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))] +pub mod tokio; + +/// Represents an agnostic asynchronous runtime that can perform tasks that Tatsuki requires. +pub trait Runtime: Sized + Send + Sync { + /// Spawns a [`Future`] onto a runtime. + fn spawn(&self, fut: F) + where + F::Output: Send + 'static; + + /// Return a [`Future`] that resolves in `duration` time. + fn sleep(&self, duration: Duration) -> Pin>; +} + +/// Future returned by [`Runtime::sleep`]. +pub trait Sleep: Future + Send + Sync {} diff --git a/crates/tatsuki/src/rt/tokio.rs b/crates/tatsuki/src/rt/tokio.rs new file mode 100644 index 000000000..642209dc4 --- /dev/null +++ b/crates/tatsuki/src/rt/tokio.rs @@ -0,0 +1,56 @@ +// 🐻‍❄️🗻 tatsuki: Dead simple asynchronous job scheduler that is runtime-agnostic. +// Copyright 2024 Noel Towa +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! [`Runtime`] implementation using Tokio + +use crate::rt::{self, Runtime}; +use pin_project_lite::pin_project; +use std::{future::Future, pin::Pin}; + +/// [`Runtime`] implementation using the [`tokio`] library. +#[derive(Clone, Default)] +pub struct Tokio; + +impl Runtime for Tokio { + fn spawn(&self, fut: F) + where + F::Output: Send + 'static, + { + tokio::task::spawn(fut); + } + + fn sleep(&self, duration: std::time::Duration) -> Pin> { + Box::pin(Sleep { + inner: tokio::time::sleep(duration), + }) + } +} + +pin_project! { + pub(crate) struct Sleep { + #[pin] + pub(crate) inner: tokio::time::Sleep + } +} + +impl Future for Sleep { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { + self.project().inner.poll(cx) + } +} + +impl rt::Sleep for Sleep {} diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 060feea57..482604e92 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -33,12 +33,8 @@ azalia = { workspace = true, features = ["config"] } chrono.workspace = true charted-core = { version = "0.1.0", default-features = false, path = "../core" } charted-database = { version = "0.1.0", path = "../database" } -diesel = { workspace = true, features = [ - "chrono", - "postgres", - "sqlite", - "uuid", -] } +diesel.workspace = true +diesel-derive-enum = { version = "2.1.0", features = ["postgres", "sqlite"] } paste = "1.0.15" schemars = { workspace = true, optional = true } semver.workspace = true diff --git a/crates/types/src/db.rs b/crates/types/src/db.rs index 8a2612a86..4d5f3d816 100644 --- a/crates/types/src/db.rs +++ b/crates/types/src/db.rs @@ -13,15 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{helm::ChartType, name::Name, DateTime, Ulid, Version}; +use crate::{helm::ChartType, name::Name, util, DateTime, Ulid, Version}; use charted_core::bitflags::ApiKeyScopes; -use diesel::prelude::{Insertable, Queryable}; +use diesel::prelude::*; use serde::Serialize; use utoipa::ToSchema; #[derive(Debug, Clone, Serialize, ToSchema, Queryable, Insertable)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite, diesel::pg::Pg))] #[diesel(table_name = charted_database::schema::postgresql::users)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite, diesel::pg::Pg))] #[diesel(table_name = charted_database::schema::sqlite::users)] pub struct User { /// whether or not if this user is considered a verified publisher. @@ -53,6 +53,12 @@ pub struct User { /// Name of this user that can be identified easier. pub username: Name, + #[serde(skip)] + pub password: Option, + + #[serde(skip)] + pub email: String, + /// Whether if this User is an Administrator of this instance #[serde(default)] #[schema(read_only)] @@ -64,14 +70,23 @@ pub struct User { /// Unique identifier to locate this user via the REST API. pub id: Ulid, - - #[serde(skip)] - pub password: Option, - - #[serde(skip)] - pub email: Option, } +util::selectable!(users for User => [ + verified_publisher: bool, + gravatar_email: Option, + description: Option, + avatar_hash: Option, + created_at: DateTime, + updated_at: DateTime, + username: Name, + password: Option, + email: String, + admin: bool, + name: Option, + id: Ulid +]); + #[derive(Debug, Clone, Serialize, ToSchema, Queryable, Insertable)] #[diesel(check_for_backend(diesel::sqlite::Sqlite, diesel::pg::Pg))] #[diesel(table_name = charted_database::schema::postgresql::user_connections)] @@ -103,6 +118,15 @@ pub struct UserConnections { pub id: Ulid, } +util::selectable!(user_connections for UserConnections => [ + noelware_account_id: Option, + google_account_id: Option, + github_account_id: Option, + created_at: DateTime, + updated_at: DateTime, + id: Ulid +]); + #[derive(Debug, Clone, Serialize, ToSchema, Queryable, Insertable)] #[diesel(check_for_backend(diesel::sqlite::Sqlite, diesel::pg::Pg))] #[diesel(table_name = charted_database::schema::postgresql::repositories)] @@ -156,6 +180,20 @@ pub struct Repository { pub id: Ulid, } +util::selectable!(repositories for Repository => [ + description: Option, + deprecated: bool, + created_at: DateTime, + updated_at: DateTime, + icon_hash: Option, + creator: Option, + private: bool, + owner: Ulid, + name: Name, + type_: ChartType, + id: Ulid +]); + /// Represents a resource that contains a release from a [Repository] release. Releases /// are a way to group releases of new versions of Helm charts that can be easily /// fetched from the API server. @@ -193,6 +231,15 @@ pub struct RepositoryRelease { pub id: Ulid, } +util::selectable!(repository_releases for RepositoryRelease => [ + update_text: Option, + repository: Ulid, + created_at: DateTime, + updated_at: DateTime, + tag: Version, + id: Ulid +]); + macro_rules! create_member_struct { ($name:ident -> $table:ident) => { paste::paste! { @@ -233,6 +280,15 @@ macro_rules! create_member_struct { ::charted_core::bitflags::MemberPermissions::new(self.permissions.try_into().expect("cannot convert to u64")) } } + + $crate::util::selectable!($table for [<$name Member>] => [ + display_name: Option, + permissions: i64, + updated_at: DateTime, + joined_at: DateTime, + account: Ulid, + id: Ulid + ]); } }; } @@ -292,6 +348,20 @@ pub struct Organization { pub id: Ulid, } +util::selectable!(organizations for Organization => [ + verified_publisher: bool, + twitter_handle: Option, + gravatar_email: Option, + display_name: Option, + created_at: DateTime, + updated_at: DateTime, + icon_hash: Option, + private: bool, + owner: Ulid, + name: Name, + id: Ulid +]); + /// A resource for personal-managed API tokens that is created by a User. This is useful /// for command line tools or scripts that need to interact with charted-server, but /// the main use-case is for the [Helm plugin](https://charts.noelware.org/docs/helm-plugin/current). @@ -323,9 +393,9 @@ pub struct ApiKey { /// The token itself. This is never revealed when querying, but only revealed /// when you create the token. - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] #[schema(read_only)] - pub token: Option, + pub token: String, /// User resource that owns this API key. This is skipped /// when using the API as this is pretty useless. @@ -346,4 +416,69 @@ impl ApiKey { pub fn bitfield(&self) -> ApiKeyScopes { ApiKeyScopes::new(self.scopes.try_into().unwrap()) } + + /// Sanitize the output of [`ApiKey`] when serializing it or else the token will be + /// exposed and we don't want that. :( + pub fn sanitize(self) -> ApiKey { + ApiKey { + token: String::new(), + ..self + } + } } + +util::selectable!(api_keys for ApiKey => [ + description: Option, + created_at: DateTime, + updated_at: DateTime, + expires_in: Option, + scopes: i64, + token: String, + owner: Ulid, + name: Name, + id: Ulid +]); + +/// Resource that represents a user session present. +#[derive(Debug, Clone, Serialize, ToSchema, Queryable, Insertable)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite, diesel::pg::Pg))] +#[diesel(table_name = charted_database::schema::postgresql::sessions)] +#[diesel(table_name = charted_database::schema::sqlite::sessions)] +pub struct Session { + /// Refresh token to refresh this session. + /// + /// When refreshed, the session will still be alive but `access_token` + /// and this field will be different. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub refresh_token: String, + + /// Access token to access data from the REST service. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub access_token: String, + + /// ULID of the user that owns this session + pub owner: Ulid, + + /// Unique identifier of this session. + pub id: Ulid, +} + +impl Session { + /// Sanitize the `access_token` and `refresh_token` fields so that it can be passed + /// from the user sessions API. + pub fn sanitize(self) -> Session { + Session { + refresh_token: String::new(), + access_token: String::new(), + owner: self.owner, + id: self.id, + } + } +} + +util::selectable!(sessions for Session => [ + refresh_token: String, + access_token: String, + owner: Ulid, + id: Ulid +]); diff --git a/crates/types/src/helm.rs b/crates/types/src/helm.rs index 0c786ff14..7af689b5d 100644 --- a/crates/types/src/helm.rs +++ b/crates/types/src/helm.rs @@ -14,8 +14,16 @@ // limitations under the License. use crate::{DateTime, Version, VersionReq}; +use charted_database::schema::sql_types; use chrono::Utc; -use diesel::{deserialize::FromSqlRow, expression::AsExpression, sql_types::Text}; +use diesel::{ + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + pg::Pg, + serialize::ToSql, + sql_types::{Binary, Text}, + sqlite::Sqlite, +}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, str::FromStr}; use utoipa::ToSchema; @@ -39,7 +47,7 @@ pub enum ChartSpecVersion { /// when serializing to valid Helm objects #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, ToSchema, FromSqlRow, AsExpression)] #[serde(rename_all = "lowercase")] -#[diesel(sql_type = charted_database::schema::sql_types::ChartType)] +#[diesel(sql_type = sql_types::ChartType)] #[diesel(sql_type = Text)] pub enum ChartType { /// Default chart type and represents a standard chart which can operate on a Kubernetes @@ -62,6 +70,81 @@ pub enum ChartType { Operator, } +impl FromSql for ChartType { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let bytes = bytes.as_bytes(); + match bytes { + b"application" => Ok(ChartType::Application), + b"library" => Ok(ChartType::Library), + b"operator" => Ok(ChartType::Operator), + v => Err(format!("unknown enum variant: {}", String::from_utf8_lossy(v)).into()), + } + } +} + +impl ToSql for ChartType { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { + >::to_sql( + match self { + ChartType::Application => "application", + ChartType::Library => "library", + ChartType::Operator => "operator", + }, + out, + ) + } +} + +impl FromSql for ChartType { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let data = >::from_sql(bytes)?; + match data.as_bytes() { + b"application" => Ok(ChartType::Application), + b"library" => Ok(ChartType::Library), + b"operator" => Ok(ChartType::Operator), + v => Err(format!("unknown enum variant: {}", String::from_utf8_lossy(v)).into()), + } + } +} + +impl ToSql for ChartType { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + >::to_sql( + match self { + ChartType::Application => "application", + ChartType::Library => "library", + ChartType::Operator => "operator", + }, + out, + ) + } +} + +impl FromSql for ChartType { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let bytes = as FromSql>::from_sql(bytes)?; + match bytes.as_slice() { + b"application" => Ok(ChartType::Application), + b"library" => Ok(ChartType::Library), + b"operator" => Ok(ChartType::Operator), + v => Err(format!("unknown enum variant: {}", String::from_utf8_lossy(v)).into()), + } + } +} + +impl ToSql for ChartType { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + >::to_sql( + match self { + ChartType::Application => "application", + ChartType::Library => "library", + ChartType::Operator => "operator", + }, + out, + ) + } +} + impl FromStr for ChartType { type Err = String; fn from_str(s: &str) -> Result { diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index d81e09a50..108037822 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -14,6 +14,9 @@ // limitations under the License. #![allow(clippy::too_long_first_doc_paragraph)] +#![feature(decl_macro)] +// #![feature(trivial_bounds)] +// #![deny(trivial_bounds)] //! The `charted-types` crate defines types that can be used within the lifecycle //! of the API server. @@ -21,6 +24,8 @@ mod db; pub use db::*; +pub(crate) mod util; + pub mod helm; pub mod name; pub mod payloads; @@ -29,9 +34,11 @@ use diesel::{ backend::Backend, deserialize::{FromSql, FromSqlRow}, expression::AsExpression, + pg::Pg, query_builder::bind_collector::RawBytesBindCollector, - serialize::ToSql, - sql_types::{Text, Timestamp, Timestamptz}, + serialize::{IsNull, ToSql}, + sql_types::{Text, Timestamp, Timestamptz, TimestamptzSqlite}, + sqlite::Sqlite, }; use serde::{Deserialize, Serialize}; use std::fmt::Display; @@ -51,13 +58,14 @@ charted_core::create_newtype_wrapper! { /// Newtype wrapper for the [`chrono::DateTime`]<[`chrono::Utc`]> type. It implements /// the following traits: /// - /// * [`AsExpression`]<[`Timestamp`]> + /// * [`AsExpression`]<[`TimestamptzSqlite`]> /// * [`AsExpression`]<[`Timestamptz`]> /// * [`utoipa::ToSchema`] #[cfg_attr(feature = "jsonschema", doc = "* [`schemars::JsonSchema`](https://docs.rs/schemars/*/schemars/trait.JsonSchema.html)")] - #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, AsExpression)] - #[diesel(sql_type = Timestamp)] + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, AsExpression, FromSqlRow)] + #[diesel(sql_type = TimestamptzSqlite)] #[diesel(sql_type = Timestamptz)] + #[diesel(sql_type = Timestamp)] pub DateTime for ::chrono::DateTime<::chrono::Utc>; } @@ -96,6 +104,67 @@ impl ::schemars::JsonSchema for DateTime { } } +#[allow(trivial_bounds)] +impl ToSql for DateTime +where + ::chrono::DateTime<::chrono::Utc>: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { + as diesel::serialize::ToSql>::to_sql( + &self.0, + &mut out.reborrow(), + ) + } +} + +#[allow(trivial_bounds)] +impl FromSql for DateTime +where + ::chrono::DateTime<::chrono::Utc>: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let result: ::chrono::DateTime<::chrono::Utc> = + <::chrono::DateTime<::chrono::Utc> as FromSql>::from_sql(bytes)?; + + Ok(Self(result)) + } +} + +#[allow(trivial_bounds)] +impl ToSql for DateTime +where + ::chrono::DateTime<::chrono::Utc>: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + as diesel::serialize::ToSql>::to_sql(&self.0, out) + } +} + +#[allow(trivial_bounds)] +impl FromSql for DateTime +where + ::chrono::DateTime<::chrono::Utc>: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let result: ::chrono::DateTime<::chrono::Utc> = + <::chrono::DateTime<::chrono::Utc> as FromSql>::from_sql(bytes)?; + + Ok(Self(result)) + } +} + +impl FromSql for DateTime +where + chrono::NaiveDateTime: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let datetime = >::from_sql(bytes)?; + let converted = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); + + Ok(Self(converted)) + } +} + charted_core::create_newtype_wrapper! { /// Newtype wrapper for [`semver::Version`] which implements common traits that charted-server uses for /// API entities. @@ -127,12 +196,12 @@ where } } -impl<'s, B: Backend> FromSql for Version +impl FromSql for Version where - &'s str: FromSql, + String: FromSql, { fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { - Ok(semver::Version::parse(<&str as FromSql>::from_sql(bytes)?) + Ok(semver::Version::parse(&>::from_sql(bytes)?) .map(Self) .map_err(Box::new)?) } @@ -238,11 +307,16 @@ charted_core::create_newtype_wrapper! { /// * [`utoipa::ToSchema`] /// * [`ToSql`]<[`Text`], [`B`][diesel::backend::Backend]> /// * [`FromSql`]<[`Text`], [`B`][diesel::backend::Backend]> - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, AsExpression)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, AsExpression, FromSqlRow)] #[diesel(sql_type = Text)] pub Ulid for ::ulid::Ulid; } +/// Exposes types from the [`ulid`] crate that can be accessible from other `charted` crates. +pub mod ulid { + pub use ::ulid::{DecodeError, EncodeError, ULID_LEN}; +} + impl Display for Ulid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { <::ulid::Ulid as Display>::fmt(&self.0, f) @@ -271,26 +345,38 @@ impl<'s> ToSchema<'s> for Ulid { } } -impl ToSql for Ulid +impl ToSql for Ulid where - for<'c> B: Backend = RawBytesBindCollector>, - str: ToSql, + str: ToSql, { - fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, B>) -> diesel::serialize::Result { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { let mut buf = [0; ulid::ULID_LEN]; let v = self.array_to_str(&mut buf); - (*v).to_sql(&mut out.reborrow()) + >::to_sql(&(*v), &mut out.reborrow()) + } +} + +// Sqlite's bind collector doesn't use `RawBytesBindCollector` like Postgres does, so we kind have to +// do it like this. Abeit, not being the best way or probably the recommended way. +impl ToSql for Ulid { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + let v = self.to_string(); + out.set_value(v); + + Ok(IsNull::No) } } -impl<'s, B: Backend> FromSql for Ulid +impl FromSql for Ulid where - &'s str: FromSql, + String: FromSql, { fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { - Ok(ulid::Ulid::from_string(<&str as FromSql>::from_sql(bytes)?) - .map(Self) - .map_err(Box::new)?) + Ok( + ::ulid::Ulid::from_string(&>::from_sql(bytes)?) + .map(Self) + .map_err(Box::new)?, + ) } } diff --git a/crates/types/src/name.rs b/crates/types/src/name.rs index 0315d5fc0..63a92bcdb 100644 --- a/crates/types/src/name.rs +++ b/crates/types/src/name.rs @@ -14,8 +14,13 @@ // limitations under the License. use diesel::{ - backend::Backend, deserialize::FromSql, expression::AsExpression, - query_builder::bind_collector::RawBytesBindCollector, serialize::ToSql, sql_types::Text, + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + pg::Pg, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, }; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, fmt::Display, ops::Deref, str::FromStr, sync::Arc}; @@ -74,7 +79,7 @@ impl std::error::Error for Error {} /// * Only UTF-8 strings are valid. /// * Only alphanumeric characters, `-`, `_`, and `~` are allowed. /// * They must contain a length of two minimum and 32 maximum. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, AsExpression)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, AsExpression, FromSqlRow)] #[diesel(sql_type = Text)] pub struct Name(Arc); impl Name { @@ -181,23 +186,25 @@ impl<'de> Deserialize<'de> for Name { } } -impl ToSql for Name +impl FromSql for Name where - for<'c> B: Backend = RawBytesBindCollector>, - str: ToSql, + String: FromSql, { - fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, B>) -> diesel::serialize::Result { - let v = &*self.0; - v.to_sql(&mut out.reborrow()) + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let name = >::from_sql(bytes)?; + Name::try_new(name).map_err(Into::into) } } -impl<'s, B: Backend> FromSql for Name -where - &'s str: FromSql, -{ - fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { - Ok(Name::try_new(<&str as FromSql>::from_sql(bytes)?).map_err(Box::new)?) +impl ToSql for Name { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { + >::to_sql(self.as_str(), out) + } +} + +impl ToSql for Name { + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + >::to_sql(self.as_str(), out) } } diff --git a/crates/types/src/util.rs b/crates/types/src/util.rs new file mode 100644 index 000000000..25d8426a0 --- /dev/null +++ b/crates/types/src/util.rs @@ -0,0 +1,103 @@ +// 🐻‍❄️📦 charted-server: Free, open source, and reliable Helm Chart registry made in Rust +// Copyright 2022-2024 Noelware, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +macro dummy_const($($tt:tt)*) { + #[allow(unused_imports)] + const _: () = { + $($tt)* + }; +} + +/// Our own version of #\[derive([Selectable][diesel::expression::Selectable])\] that +/// only works on what databases we support since Diesel doesn't do that as of v2.2.3 +pub macro selectable { + ($table:ident for $ty:ty => [$($field:ident: $field_ty:ty),*]) => { + $crate::util::selectable! { + @__impl sqlite($table) => $ty [$($field: $field_ty),*] + } + + $crate::util::selectable! { + @__impl postgresql($table) => $ty [$($field: $field_ty),*] + } + }, + + (@__impl sqlite($table:ident) => $ty:ty [$($field:ident: $field_ty:ty),*]) => { + $crate::util::dummy_const! { + impl ::diesel::expression::Selectable< + ::diesel::sqlite::Sqlite + > for $ty { + type SelectExpression = ( + $( + ::charted_database::schema::sqlite::$table::$field, + )* + ); + + fn construct_selection() -> Self::SelectExpression { + ( + $( + ::charted_database::schema::sqlite::$table::$field, + )* + ) + } + } + + fn __type_check_compat() + where + $( + $field_ty: ::diesel::deserialize::FromSql< + ::diesel::dsl::SqlTypeOf< + ::charted_database::schema::sqlite::$table::$field, + >, + ::diesel::sqlite::Sqlite, + >, + )* + {} + } + }, + + (@__impl postgresql($table:ident) => $ty:ty [$($field:ident: $field_ty:ty),*]) => { + $crate::util::dummy_const! { + impl ::diesel::expression::Selectable< + ::diesel::pg::Pg + > for $ty { + type SelectExpression = ( + $( + ::charted_database::schema::postgresql::$table::$field, + )* + ); + + fn construct_selection() -> Self::SelectExpression { + ( + $( + ::charted_database::schema::postgresql::$table::$field, + )* + ) + } + } + + fn __type_check_compat() + where + $( + $field_ty: ::diesel::deserialize::FromSql< + ::diesel::dsl::SqlTypeOf< + ::charted_database::schema::postgresql::$table::$field, + >, + ::diesel::pg::Pg, + >, + )* + {} + } + } +}