diff --git a/Cargo.lock b/Cargo.lock index 0685623..c8684fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -505,6 +511,20 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.10" @@ -817,6 +837,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -875,9 +901,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -900,6 +928,29 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.3", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "group" version = "0.13.0" @@ -936,6 +987,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1434,6 +1491,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1635,6 +1698,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1713,6 +1782,21 @@ dependencies = [ "yansi", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.45" @@ -1741,10 +1825,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -1766,6 +1860,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1775,12 +1879,30 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2065,7 +2187,10 @@ dependencies = [ "byteorder", "chrono", "config", + "dashmap", + "governor", "jsonwebtoken", + "nonzero_ext", "rand 0.10.0", "rmp-serde", "rocket", @@ -2073,6 +2198,7 @@ dependencies = [ "rust-embed", "serde", "serde_with", + "tokio-util", "zxcvbn", ] @@ -2317,6 +2443,15 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -2810,6 +2945,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2819,6 +2980,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index aa5b5cc..95878d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,20 +22,62 @@ repository = "https://github.com/dotstart/safe_house" license = "AGPL-3" [features] +default = ["ratelimit"] +ratelimit = ["dep:dashmap", "dep:governor"] ui = ["dep:rust-embed"] [dependencies] base32 = "0.5.1" bcrypt = "0.19.0" byteorder = "1.5.0" -chrono = { version = "0.4.44", features = ["serde"] } -config = { version = "0.15.22", features = ["toml", "convert-case"], default-features = false } -jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"], default-features = false } -rand = { version = "0.10.0", features = ["thread_rng"] } +nonzero_ext = "0.3.0" rmp-serde = "1.3.1" -rocket = { version = "0.5.1", features = ["json"] } -rocksdb = { version = "0.24.0", features = ["multi-threaded-cf"] } -rust-embed = { version = "8.11.0", features = ["compression", "deterministic-timestamps", "rocket"], optional = true } -serde = { version = "1.0.228", features = ["derive"] } -serde_with = { version = "3.18.0", features = ["base64"] } -zxcvbn = "3.1.0" \ No newline at end of file +tokio-util = "0.7.18" +zxcvbn = "3.1.0" + +[dependencies.chrono] +version = "0.4.44" +features = ["serde"] + +[dependencies.config] +version = "0.15.22" +features = ["toml", "convert-case"] +default-features = false + +[dependencies.dashmap] +version = "6.1.0" +optional = true + +[dependencies.governor] +version = "0.10.4" +optional = true + +[dependencies.jsonwebtoken] +version = "10.3.0" +features = ["rust_crypto"] +default-features = false + +[dependencies.rand] +version = "0.10.0" +features = ["thread_rng"] + +[dependencies.rocket] +version = "0.5.1" +features = ["json"] + +[dependencies.rocksdb] +version = "0.24.0" +features = ["multi-threaded-cf"] + +[dependencies.rust-embed] +version = "8.11.0" +features = ["compression", "deterministic-timestamps", "rocket"] +optional = true + +[dependencies.serde] +version = "1.0.228" +features = ["derive"] + +[dependencies.serde_with] +version = "3.18.0" +features = ["base64"] \ No newline at end of file diff --git a/safe_house.example.toml b/safe_house.example.toml index 0051fb7..53dd05c 100644 --- a/safe_house.example.toml +++ b/safe_house.example.toml @@ -52,4 +52,45 @@ expiration_job_minutes = 30 # # Environment: SAFEHOUSE_DOCUMENT_KEY_DERIVATION_ROUNDS # Default: 600_000 -key_derivation_rounds = 600000 \ No newline at end of file +key_derivation_rounds = 600000 + +[ratelimit] +# Rate Limit Enabled +# ------------------ +# Whether rate limiting shall be applied to vulnerable endpoints. +# +# Default: true +enabled = true + +# Login Burst Limit +# ----------------- +# Maximum number of burst login requests permitted to be sent at a given time. +# +# Default: 8 +auth_login_burst = 8 + +# Login Limit Regain Period +# ------------------------- +# Defines the amount of minutes that have to pass before another request to the login endpoint is +# added to the remaining quota. +# +# This value effectively describes the maximum frequency at which logins may be performed (e.g. +# every 10 minutes if set to 10). +# +# Default: 10 +auth_login_regain_minutes = 10 + +# Document Creation Limit +# ----------------------- +# Maximum number of documents which may be created per hour from a given network address. +# +# Default: 10 +document_creations_per_hour = 10 + +# Document View Limit +# ------------------- +# Maximum number of document views which may be performed within a given hour for a given network +# address. +# +# Default: 30 +document_views_per_hour = 30 \ No newline at end of file diff --git a/src/cfg/db.rs b/src/cfg/db.rs index 752ee42..4b2b912 100644 --- a/src/cfg/db.rs +++ b/src/cfg/db.rs @@ -25,13 +25,6 @@ pub struct Database { } impl Database { - pub fn defaults() -> Self { - Self { - path: "./data".to_string(), - compress: true, - } - } - pub fn dump_to_log(&self) { info!(" >> db.path: {}", self.path); info!(" >> db.comress: {}", self.compress); @@ -53,3 +46,12 @@ impl Database { ) } } + +impl Default for Database { + fn default() -> Self { + Self { + path: "./data".to_string(), + compress: true, + } + } +} diff --git a/src/cfg/document.rs b/src/cfg/document.rs index 430d228..736a126 100644 --- a/src/cfg/document.rs +++ b/src/cfg/document.rs @@ -25,13 +25,6 @@ pub struct Document { } impl Document { - pub fn defaults() -> Self { - Self { - expiration_job_minutes: 30, - key_derivation_rounds: 600_000, - } - } - pub fn dump_to_log(&self) { info!( " >> document.expiration_job_minutes: {}", @@ -59,3 +52,12 @@ impl Document { ) } } + +impl Default for Document { + fn default() -> Self { + Self { + expiration_job_minutes: 30, + key_derivation_rounds: 600_000, + } + } +} diff --git a/src/cfg/mod.rs b/src/cfg/mod.rs index 4b0aeae..617663e 100644 --- a/src/cfg/mod.rs +++ b/src/cfg/mod.rs @@ -17,38 +17,34 @@ */ pub mod db; pub mod document; +pub mod ratelimit; pub mod security; use crate::cfg::db::Database; use crate::cfg::document::Document; +use crate::cfg::ratelimit::RateLimitConfig; use crate::cfg::security::Security; use config::{ConfigError, Map, Source, Value}; use rocket::serde::Deserialize; -#[derive(Clone, Deserialize, Debug)] +#[derive(Default, Clone, Deserialize, Debug)] pub struct ApplicationConfig { pub database: Database, pub security: Security, pub document: Document, + pub ratelimit: RateLimitConfig, } impl ApplicationConfig { pub const LOCATION: &'static str = "safe_house.toml"; - pub fn defaults() -> Self { - Self { - database: Database::defaults(), - security: Security::defaults(), - document: Document::defaults(), - } - } - pub fn dump_to_log(&self) { info!("safe_house Configuration:"); self.database.dump_to_log(); self.security.dump_to_log(); self.document.dump_to_log(); + self.ratelimit.dump_to_log(); } } @@ -63,6 +59,7 @@ impl Source for ApplicationConfig { ("database".to_string(), self.database.collect()), ("security".to_string(), self.security.collect()), ("document".to_string(), self.document.collect()), + ("ratelimit".to_string(), self.ratelimit.collect()), ])) } } diff --git a/src/cfg/ratelimit.rs b/src/cfg/ratelimit.rs new file mode 100644 index 0000000..ce6dd51 --- /dev/null +++ b/src/cfg/ratelimit.rs @@ -0,0 +1,104 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use config::{Map, Value, ValueKind}; +use nonzero_ext::nonzero; +use rocket::serde::Deserialize; +use std::num::{NonZeroU32, NonZeroU64}; + +#[derive(Clone, Deserialize, Debug)] +pub struct RateLimitConfig { + pub enabled: bool, + + pub auth_login_burst: NonZeroU32, + pub auth_login_regain_minutes: NonZeroU64, + + pub document_creations_per_hour: NonZeroU32, + pub document_views_per_hour: NonZeroU32, +} + +impl RateLimitConfig { + pub fn dump_to_log(&self) { + info!(" >> ratelimit.enabled: {}", self.enabled); + + info!( + " >> ratelimit.auth_login_burst: {}", + self.auth_login_burst + ); + info!( + " >> ratelimit.auth_login_regain_minutes: {}", + self.auth_login_regain_minutes + ); + + info!( + " >> ratelimit.document_creations_per_hour: {}", + self.document_creations_per_hour + ); + info!( + " >> ratelimit.document_views_per_hour: {}", + self.document_views_per_hour + ); + } + + pub fn collect(&self) -> Value { + Value::new( + None, + ValueKind::Table(Map::from([ + ( + "enabled".to_string(), + Value::new(None, ValueKind::Boolean(self.enabled)), + ), + ( + "auth_login_burst".to_string(), + Value::new(None, ValueKind::U64(self.auth_login_burst.get() as u64)), + ), + ( + "auth_login_regain_minutes".to_string(), + Value::new(None, ValueKind::U64(self.auth_login_regain_minutes.get())), + ), + ( + "document_creations_per_hour".to_string(), + Value::new( + None, + ValueKind::U64(self.document_creations_per_hour.get() as u64), + ), + ), + ( + "document_views_per_hour".to_string(), + Value::new( + None, + ValueKind::U64(self.document_views_per_hour.get() as u64), + ), + ), + ])), + ) + } +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + enabled: true, + + auth_login_burst: nonzero!(8u32), + auth_login_regain_minutes: nonzero!(10u64), + + document_creations_per_hour: nonzero!(10u32), + document_views_per_hour: nonzero!(30u32), + } + } +} diff --git a/src/cfg/security.rs b/src/cfg/security.rs index 79f99ea..e7c9aed 100644 --- a/src/cfg/security.rs +++ b/src/cfg/security.rs @@ -25,12 +25,6 @@ pub struct Security { } impl Security { - pub fn defaults() -> Self { - Self { - auth_method: AuthMethod::DEFAULT, - } - } - pub fn dump_to_log(&self) { info!(" >> security.auth_method: {}", self.auth_method); } @@ -46,6 +40,14 @@ impl Security { } } +impl Default for Security { + fn default() -> Self { + Self { + auth_method: AuthMethod::DEFAULT, + } + } +} + #[derive(Copy, Clone, Deserialize, Debug)] pub enum AuthMethod { None, diff --git a/src/main.rs b/src/main.rs index 9116358..c4c9012 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,10 +29,11 @@ extern crate rocket; use crate::cfg::security::AuthMethod; use crate::cfg::ApplicationConfig; use crate::security::token::InternalTokenProvider; +use crate::store::system::migration::Migrate; +use crate::store::system::{MigrationResult, SchemaVersion}; use config::{Case, Config, Environment, File}; use rocket::fairing::AdHoc; -use rocket::tokio::time::interval; -use rocket::{tokio, Build, Rocket}; +use rocket::{Build, Rocket}; use std::path::Path; use std::time::Duration; @@ -40,14 +41,15 @@ use std::time::Duration; mod assets; mod cfg; mod feature; +mod ratelimit; mod routes; mod security; mod store; #[rocket::main] async fn main() -> Result<(), rocket::Error> { - let s = match Config::builder() - .add_source(ApplicationConfig::defaults()) + let s = Config::builder() + .add_source(ApplicationConfig::default()) .add_source(File::with_name(ApplicationConfig::LOCATION).required(false)) .add_source( Environment::with_prefix("safehouse") @@ -55,35 +57,22 @@ async fn main() -> Result<(), rocket::Error> { .convert_case(Case::Snake), ) .build() - { - Ok(s) => s, - Err(e) => { - panic!("Failed to load configuration file: {}", e) - } - }; + .expect("Failed to load configuration file"); - let config = match s.try_deserialize::() { - Ok(c) => c, - Err(e) => { - panic!("Failed to deserialize configuration file: {}", e) - } - }; + let config = s + .try_deserialize::() + .expect("Failed to deserialize configuration file"); let store_cfg = store::Config { path: Path::new(config.database.path.as_str()).to_path_buf(), compress: config.database.compress, }; - let db = match store::open_database(&store_cfg) { - Ok(db) => db, - Err(e) => panic!("Database failed initialization: {}", e), - }; + let db = store::open_database(&store_cfg).expect("Database failed initialization"); + let system_store = store::system::Repository::new(&db); let document_store = store::document::Repository::new(&db); - let system_store = match store::system::Repository::new(&db) { - Ok(store) => store, - Err(e) => panic!("System store failed initialization: {}", e), - }; + let internal_user_store = store::internal_user::Repository::new(&db); let mut r = rocket::build() .manage(config.clone()) @@ -94,41 +83,32 @@ async fn main() -> Result<(), rocket::Error> { r = r.manage(security::auth_none::stage()); } AuthMethod::Internal => { - let internal_user_store = store::internal_user::Repository::new(&db); - let token_provider = InternalTokenProvider::new(system_store); + let token_provider = InternalTokenProvider::new(system_store.clone()); r = r .manage(internal_user_store.clone()) .attach(security::auth_internal::stage( - internal_user_store, + internal_user_store.clone(), token_provider, )); } }; - let expiry_task_period = Duration::from_mins(config.document.expiration_job_minutes); - tokio::spawn(async move { - let mut i = interval(expiry_task_period); - loop { - i.tick().await; - - info!("Removing expired documents ..."); - match document_store.expire() { - Ok(count) => { - info!("Deleted {} expired documents", count); - } - Err(e) => { - warn!("Failed to delete expired documents: {}", e); - } - } - } - }); - + r = attach_rate_limit(&config, r); r = attach_web_ui(r); r = r .attach(routes::stage()) - .attach(AdHoc::on_liftoff("Dump Config", |_| { + .attach(migrate( + system_store, + document_store.clone(), + internal_user_store, + )) + .attach(store::document::cleanup::CleanupFairing::new( + document_store, + Duration::from_mins(config.document.expiration_job_minutes), + )) + .attach(AdHoc::on_liftoff("Finalize Startup", |_| { Box::pin(async move { config.dump_to_log(); }) @@ -139,12 +119,63 @@ async fn main() -> Result<(), rocket::Error> { Ok(()) } +fn migrate( + system_store: store::system::Repository, + document_store: store::document::Repository, + internal_user_store: store::internal_user::Repository, +) -> AdHoc { + AdHoc::try_on_ignite("Prepare Startup", |rocket| async move { + match system_store.check_migration() { + Ok(MigrationResult::UpToDate(version)) => { + info!("Store schema is up to date (v{})", version); + } + Ok(MigrationResult::Required(version)) => { + info!( + "Store schema migration required (current: {}, target: {})", + version, + SchemaVersion::LATEST + ); + document_store + .migrate(version.clone()) + .expect("Failed to migrate document store"); + internal_user_store + .migrate(version.clone()) + .expect("Failed to migrate internal user store"); + } + Ok(MigrationResult::UnknownVersion(version)) => { + panic!("Unknown store schema (v{})", version) + } + Err(e) => panic!("Store migration check failed: {}", e), + }; + + system_store + .complete_migration() + .expect("Failed to complete migration"); + + Ok(rocket) + }) +} + +#[cfg(feature = "ratelimit")] +fn attach_rate_limit(cfg: &ApplicationConfig, rocket: Rocket) -> Rocket { + if !cfg.ratelimit.enabled { + return rocket; + } + + rocket.attach(ratelimit::stage()) +} + +#[cfg(not(feature = "ratelimit"))] +fn attach_rate_limit(cfg: &ApplicationConfig, rocket: Rocket) -> Rocket { + rocket +} + #[cfg(feature = "ui")] -pub fn attach_web_ui(rocket: Rocket) -> Rocket { +fn attach_web_ui(rocket: Rocket) -> Rocket { rocket.attach(assets::stage()) } #[cfg(not(feature = "ui"))] -pub fn attach_web_ui(rocket: Rocket) -> Rocket { +fn attach_web_ui(rocket: Rocket) -> Rocket { rocket } diff --git a/src/ratelimit/guard.rs b/src/ratelimit/guard.rs new file mode 100644 index 0000000..d94f4e7 --- /dev/null +++ b/src/ratelimit/guard.rs @@ -0,0 +1,119 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#[cfg(feature = "ratelimit")] +use crate::cfg::ApplicationConfig; +#[cfg(feature = "ratelimit")] +use crate::ratelimit::policy::Bucket; +use crate::ratelimit::policy::Policy; +#[cfg(feature = "ratelimit")] +use crate::ratelimit::store::RateLimitResult; +#[cfg(feature = "ratelimit")] +use crate::ratelimit::store::Registry; +#[cfg(feature = "ratelimit")] +use crate::security::auth::User; +#[cfg(feature = "ratelimit")] +use crate::security::permission::PermissionFlag; +#[cfg(feature = "ratelimit")] +use governor::clock::{Clock, DefaultClock}; +#[cfg(feature = "ratelimit")] +use rocket::http::Status; +#[cfg(feature = "ratelimit")] +use rocket::outcome::try_outcome; +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; +use std::marker::PhantomData; + +pub struct RateLimit(PhantomData

); + +impl RateLimit

{ + fn new() -> Self { + Self(PhantomData) + } +} + +#[rocket::async_trait] +impl<'r, P: Policy> FromRequest<'r> for RateLimit

{ + type Error = std::convert::Infallible; + + #[cfg(feature = "ratelimit")] + async fn from_request(request: &'r Request<'_>) -> Outcome { + let user = try_outcome!(request.guard::().await); + if user.has_permission(&PermissionFlag::BypassRateLimit) { + return Outcome::Success(RateLimit::new()); + } + + let config = request + .rocket() + .state::() + .expect("application config should be available as managed state"); + + let registry = match request.rocket().state::() { + Some(r) => r, + None => { + // registry is not registered with rocket so the guard is simply skipped - this allows + // us to add rate limiters to handler functions despite the feature being disabled + return Outcome::Success(RateLimit::new()); + } + }; + + let quota = P::quota(&config.ratelimit); + let outcome = match P::bucket(&config.ratelimit) { + Bucket::None => registry.get(P::name().to_string(), quota).check(), + Bucket::Network => { + let limiter = registry.get_keyed(P::name().to_string(), quota); + let remote = match request.client_ip() { + Some(ip) => ip.to_string(), + None => return Outcome::Success(RateLimit::new()), + }; + + limiter.check_key(&remote) + } + Bucket::User => { + let limiter = registry.get_keyed(P::name().to_string(), quota); + + limiter.check_key(&user.id()) + } + }; + + match outcome { + Ok(state) => { + request.local_cache(|| { + RateLimitResult::Permitted( + state.quota().burst_size().get(), + state.remaining_burst_capacity(), + ) + }); + Outcome::Success(RateLimit::new()) + } + Err(state) => { + request.local_cache(|| { + RateLimitResult::Rejected( + state.quota().burst_size().get(), + state.wait_time_from(DefaultClock::default().now()), + ) + }); + Outcome::Forward(Status::TooManyRequests) + } + } + } + + #[cfg(not(feature = "ratelimit"))] + async fn from_request(_request: &'r Request<'_>) -> Outcome { + Outcome::Success(RateLimit(PhantomData)) + } +} diff --git a/src/ratelimit/mod.rs b/src/ratelimit/mod.rs new file mode 100644 index 0000000..b4dc05c --- /dev/null +++ b/src/ratelimit/mod.rs @@ -0,0 +1,82 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#[cfg(feature = "ratelimit")] +use crate::ratelimit::store::RateLimitResult; +#[cfg(feature = "ratelimit")] +use rocket::fairing::AdHoc; +#[cfg(feature = "ratelimit")] +use rocket::fairing::Info; +#[cfg(feature = "ratelimit")] +use rocket::fairing::Kind; +#[cfg(feature = "ratelimit")] +use rocket::http::Header; +#[cfg(feature = "ratelimit")] +use rocket::{Request, Response}; + +pub mod guard; +pub mod policy; + +#[cfg(feature = "ratelimit")] +mod store; + +#[cfg(feature = "ratelimit")] +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Rate Limiter", |rocket| async { + rocket.manage(store::Registry::new()).attach(Fairing) + }) +} + +#[cfg(feature = "ratelimit")] +struct Fairing; + +#[cfg(feature = "ratelimit")] +#[rocket::async_trait] +impl rocket::fairing::Fairing for Fairing { + fn info(&self) -> Info { + Info { + name: "Rate Limit Processor", + kind: Kind::Response, + } + } + + async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) { + // this is a bit hacky but there doesn't seem to be a good way to communicate between + // guards and response callbacks at the moment + let result = req.local_cache(|| RateLimitResult::None); + + match result { + RateLimitResult::Permitted(limit, remaining) => { + res.set_header(Header::new("X-RateLimit-Limit", limit.to_string())); + res.set_header(Header::new("X-RateLimit-Remaining", remaining.to_string())); + + if (*remaining as f32) / (*limit as f32) <= 0.25f32 { + res.set_header(Header::new("X-RateLimit-NearLimit", "true")); + } + } + RateLimitResult::Rejected(limit, window) => { + res.set_header(Header::new("X-RateLimit-Limit", limit.to_string())); + res.set_header(Header::new("X-RateLimit-Remaining", "0")); + res.set_header(Header::new( + "X-RateLimit-RetryAfter", + window.as_secs().to_string(), + )); + } + RateLimitResult::None => {} + } + } +} diff --git a/src/ratelimit/policy/auth.rs b/src/ratelimit/policy/auth.rs new file mode 100644 index 0000000..6253698 --- /dev/null +++ b/src/ratelimit/policy/auth.rs @@ -0,0 +1,38 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#[cfg(feature = "ratelimit")] +use crate::cfg::ratelimit::RateLimitConfig; +use crate::ratelimit::policy::Policy; +#[cfg(feature = "ratelimit")] +use governor::Quota; +use std::time::Duration; + +pub struct Login; + +impl Policy for Login { + fn name() -> &'static str { + "auth_login" + } + + #[cfg(feature = "ratelimit")] + fn quota(cfg: &RateLimitConfig) -> Quota { + Quota::with_period(Duration::from_mins(cfg.auth_login_regain_minutes.get())) + .unwrap() // cannot fail since cfg uses NonZero + .allow_burst(cfg.auth_login_burst) + } +} diff --git a/src/ratelimit/policy/document.rs b/src/ratelimit/policy/document.rs new file mode 100644 index 0000000..43addfe --- /dev/null +++ b/src/ratelimit/policy/document.rs @@ -0,0 +1,48 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#[cfg(feature = "ratelimit")] +use crate::cfg::ratelimit::RateLimitConfig; +use crate::ratelimit::policy::Policy; +#[cfg(feature = "ratelimit")] +use governor::Quota; + +pub struct CreateDocument; + +impl Policy for CreateDocument { + fn name() -> &'static str { + "create_document" + } + + #[cfg(feature = "ratelimit")] + fn quota(cfg: &RateLimitConfig) -> Quota { + Quota::per_hour(cfg.document_creations_per_hour) + } +} + +pub struct ViewDocument; + +impl Policy for ViewDocument { + fn name() -> &'static str { + "view_document" + } + + #[cfg(feature = "ratelimit")] + fn quota(cfg: &RateLimitConfig) -> Quota { + Quota::per_hour(cfg.document_views_per_hour) + } +} diff --git a/src/ratelimit/policy/mod.rs b/src/ratelimit/policy/mod.rs new file mode 100644 index 0000000..694a95e --- /dev/null +++ b/src/ratelimit/policy/mod.rs @@ -0,0 +1,38 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use crate::cfg::ratelimit::RateLimitConfig; + +pub mod auth; +pub mod document; + +pub trait Policy { + fn name() -> &'static str; + + #[cfg(feature = "ratelimit")] + fn quota(cfg: &RateLimitConfig) -> governor::Quota; + + fn bucket(_cfg: &RateLimitConfig) -> Bucket { + Bucket::Network + } +} + +pub enum Bucket { + None, + Network, + User, +} diff --git a/src/ratelimit/store.rs b/src/ratelimit/store.rs new file mode 100644 index 0000000..a30661e --- /dev/null +++ b/src/ratelimit/store.rs @@ -0,0 +1,76 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use dashmap::DashMap; +use governor::clock::DefaultClock; +use governor::middleware::StateInformationMiddleware; +use governor::state::keyed::DefaultKeyedStateStore; +use governor::state::{InMemoryState, NotKeyed}; +use governor::{Quota, RateLimiter}; +use std::sync::Arc; +use std::time::Duration; + +pub type DefaultDirectRateLimiter = + RateLimiter; +pub type DefaultKeyedRateLimit = + RateLimiter, DefaultClock, StateInformationMiddleware>; + +pub struct Registry { + direct_map: DashMap>, + keyed_map: DashMap>, +} + +impl Registry { + pub fn new() -> Self { + Self { + direct_map: DashMap::new(), + keyed_map: DashMap::new(), + } + } + + pub fn get(&self, name: String, quota: Quota) -> Arc { + Arc::clone( + self.direct_map + .entry(name) + .or_insert_with(|| { + Arc::new( + RateLimiter::direct(quota).with_middleware::(), + ) + }) + .value(), + ) + } + + pub fn get_keyed(&self, name: String, quota: Quota) -> Arc { + Arc::clone( + self.keyed_map + .entry(name) + .or_insert_with(|| { + Arc::new( + RateLimiter::keyed(quota).with_middleware::(), + ) + }) + .value(), + ) + } +} + +pub enum RateLimitResult { + Permitted(u32, u32), + Rejected(u32, Duration), + None, +} diff --git a/src/routes/auth/controller.rs b/src/routes/auth/controller.rs index 4fea7e7..5f6b3f7 100644 --- a/src/routes/auth/controller.rs +++ b/src/routes/auth/controller.rs @@ -17,6 +17,8 @@ */ use crate::feature::auth::InternalAuth; use crate::feature::Feature; +use crate::ratelimit; +use crate::ratelimit::guard::RateLimit; use crate::routes::auth::error::LoginError; use crate::routes::auth::request::LoginParameters; use crate::security::auth::{AuthenticationManager, User}; @@ -27,6 +29,7 @@ use rocket::State; #[post("/login", data = "")] pub async fn login( _f: &Feature, + _r: RateLimit, auth: &State, params: Json>, ) -> Result, LoginError> { diff --git a/src/routes/document/controller.rs b/src/routes/document/controller.rs index 32ed953..dd1fa44 100644 --- a/src/routes/document/controller.rs +++ b/src/routes/document/controller.rs @@ -15,8 +15,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +use crate::cfg::ApplicationConfig; use crate::feature::auth::InternalAuth; use crate::feature::Feature; +use crate::ratelimit; +use crate::ratelimit::guard::RateLimit; use crate::routes::document::error::DocumentError; use crate::routes::document::request::UploadBody; use crate::routes::document::response::{Document, DocumentMetadata}; @@ -33,11 +36,11 @@ use chrono::{TimeDelta, Utc}; use rocket::http::Status; use rocket::serde::json::Json; use rocket::State; -use crate::cfg::ApplicationConfig; #[post("/", data = "")] pub fn create( _p: &Permission, + _r: RateLimit, config: &State, state: &State, user: User, @@ -120,6 +123,7 @@ pub fn list_all<'a>( #[get("//metadata")] pub fn metadata<'a>( + _r: RateLimit, registry: &State, id: Clover<'a>, user: User, @@ -144,6 +148,7 @@ pub fn metadata<'a>( #[get("/")] pub async fn get<'a>( + _r: RateLimit, auth_manager: &State, registry: &State, id: Clover<'a>, diff --git a/src/security/auth_none.rs b/src/security/auth_none.rs index 12550c5..cae28ea 100644 --- a/src/security/auth_none.rs +++ b/src/security/auth_none.rs @@ -42,7 +42,7 @@ impl auth::AuthenticationProvider for AuthenticationProvider { } fn anonymous(&self) -> User<'_> { - User::anonymous(PermissionFlag::All) + User::anonymous(PermissionFlag::Safe) } async fn display_name(&self, _user_id: &str) -> Option { diff --git a/src/security/permission/mod.rs b/src/security/permission/mod.rs index 03c35d4..2ca59a3 100644 --- a/src/security/permission/mod.rs +++ b/src/security/permission/mod.rs @@ -27,7 +27,7 @@ use rocket::serde::{Deserialize, Deserializer, Serialize, Serializer}; use rocket::Request; use std::fmt::Formatter; use std::marker::PhantomData; -use std::ops::{BitAnd, BitOr, Not}; +use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Not}; pub trait PermissionIndicator { fn permission() -> PermissionFlag; @@ -66,41 +66,81 @@ macro_rules! permission_indicator { #[derive(Clone, Copy, Debug)] pub struct PermissionFlag(u64); +macro_rules! declare_permissions { + (@generate $idx:expr, $final:ident, ) => { + pub const $final: PermissionFlag = PermissionFlag(1 << ($idx)); + }; + (@generate $idx:expr, $head:ident, $($tail:ident,)*) => { + pub const $head: PermissionFlag = PermissionFlag(1 << ($idx)); + declare_permissions!(@generate $idx + 1, $($tail,)*); + }; + ($offset:expr, $($name:ident),*) => { + declare_permissions!(@generate $offset, $($name,)*); + }; +} + +macro_rules! declare_permission_aggregator { + ($name:ident, $($member:ident),*) => { + pub const $name: PermissionFlag = PermissionFlag( + $( + Self::$member.0 | + )* 0u64 + ); + }; +} + +macro_rules! declare_permission_group { + ($group:ident, $offset:expr, $($member:ident),*) => { + declare_permissions!($offset $(,$member)*); + declare_permission_aggregator!($group $(,$member)*); + }; +} + #[allow(non_upper_case_globals)] impl PermissionFlag { - pub const ViewAnyDocument: PermissionFlag = PermissionFlag(1 << 0); - pub const CreateDocument: PermissionFlag = PermissionFlag(1 << 1); - pub const DeleteOwnDocument: PermissionFlag = PermissionFlag(1 << 2); - pub const DeleteAnyDocument: PermissionFlag = PermissionFlag(1 << 3); - pub const AllDocument: PermissionFlag = PermissionFlag( - Self::ViewAnyDocument.0 - | Self::CreateDocument.0 - | Self::DeleteOwnDocument.0 - | Self::DeleteAnyDocument.0, + declare_permission_group!( + AllDocument, + 0, + ViewAnyDocument, + CreateDocument, + DeleteOwnDocument, + DeleteAnyDocument ); - - pub const ViewUser: PermissionFlag = PermissionFlag(1 << 8); - pub const CreateUser: PermissionFlag = PermissionFlag(1 << 9); - pub const EditOwnUser: PermissionFlag = PermissionFlag(1 << 10); - pub const EditAnyUser: PermissionFlag = PermissionFlag(1 << 11); - pub const DeleteOwnUser: PermissionFlag = PermissionFlag(1 << 12); - pub const DeleteAnyUser: PermissionFlag = PermissionFlag(1 << 13); - pub const AllUser: PermissionFlag = PermissionFlag( - Self::ViewUser.0 - | Self::CreateUser.0 - | Self::EditOwnUser.0 - | Self::EditAnyUser.0 - | Self::DeleteOwnUser.0 - | Self::DeleteAnyUser.0, + declare_permission_group!( + AllUser, + 8, + ViewUser, + CreateUser, + EditOwnUser, + EditAnyUser, + DeleteOwnUser, + DeleteAnyUser ); + declare_permission_group!(AllSystem, 24, BypassRateLimit); pub const None: PermissionFlag = PermissionFlag(0); - pub const All: PermissionFlag = PermissionFlag(Self::AllDocument.0 | Self::AllUser.0); + declare_permission_aggregator!( + Admin, + ViewAnyDocument, + DeleteAnyDocument, + ViewUser, + CreateUser, + EditAnyUser, + DeleteAnyUser, + AllSystem + ); + declare_permission_aggregator!(Safe, AllDocument, AllUser); + declare_permission_aggregator!(All, AllDocument, AllUser, AllSystem); #[inline] - pub fn contains(&self, other: &PermissionFlag) -> bool { + pub const fn contains(&self, other: &PermissionFlag) -> bool { (other.0 & self.0) == other.0 } + + #[inline] + pub const fn contains_any(&self, other: &PermissionFlag) -> bool { + (other.0 & self.0) != 0 + } } impl Not for PermissionFlag { @@ -119,6 +159,12 @@ impl BitOr for PermissionFlag { } } +impl BitOrAssign for PermissionFlag { + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0 + } +} + impl BitAnd for PermissionFlag { type Output = Self; @@ -127,6 +173,12 @@ impl BitAnd for PermissionFlag { } } +impl BitAndAssign for PermissionFlag { + fn bitand_assign(&mut self, rhs: Self) { + self.0 |= rhs.0 + } +} + impl Serialize for PermissionFlag { fn serialize(&self, serializer: S) -> Result where diff --git a/src/store/db.rs b/src/store/db.rs index 6adf1a0..b2e09f6 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -152,6 +152,12 @@ impl RawStore { } } +impl Clone for RawStore { + fn clone(&self) -> Self { + RawStore::new(&self.db, self.family.as_str()) + } +} + pub struct Transaction<'a, D: Document> { cf: Arc>, tx: rocksdb::Transaction<'a, TransactionDB>, diff --git a/src/store/document/cleanup.rs b/src/store/document/cleanup.rs new file mode 100644 index 0000000..24a9310 --- /dev/null +++ b/src/store/document/cleanup.rs @@ -0,0 +1,86 @@ +/* + * This file is part of safe_house - a simple E2E encrypted file sharing utility. + * Copyright (c) 2026 Yuki Donath and other contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use crate::store::document::Repository; +use rocket::fairing::{Fairing, Info, Kind}; +use rocket::tokio::select; +use rocket::tokio::time::interval; +use rocket::{tokio, Orbit, Rocket}; +use std::time::Duration; +use tokio_util::sync::CancellationToken; + +pub struct CleanupFairing { + store: Repository, + task_period: Duration, + token: CancellationToken, +} + +impl CleanupFairing { + pub fn new(store: Repository, task_period: Duration) -> Self { + Self { + store, + task_period, + token: CancellationToken::new(), + } + } + + async fn cleanup_loop(store: Repository, task_period: Duration, token: CancellationToken) { + let mut i = interval(task_period); + loop { + select! { + _ = token.cancelled() => { + return; + } + _ = i.tick() => { + info!("Removing expired documents ..."); + match store.expire() { + Ok(count) => { + info!("Deleted {} expired documents", count); + } + Err(e) => { + warn!("Failed to delete expired documents: {}", e); + } + } + } + } + } + } +} + +#[rocket::async_trait] +impl Fairing for CleanupFairing { + fn info(&self) -> Info { + Info { + name: "Document Cleanup Task", + kind: Kind::Liftoff | Kind::Shutdown, + } + } + + async fn on_liftoff(&self, _rocket: &Rocket) { + let store = self.store.clone(); + let task_period = self.task_period.clone(); + let token = self.token.clone(); + + tokio::spawn(async move { + Self::cleanup_loop(store, task_period, token).await; + }); + } + + async fn on_shutdown(&self, _rocket: &Rocket) { + self.token.cancel(); + } +} diff --git a/src/store/document/mod.rs b/src/store/document/mod.rs index 7b6da3f..4fe3fc0 100644 --- a/src/store/document/mod.rs +++ b/src/store/document/mod.rs @@ -18,6 +18,7 @@ use crate::store::db::{Store, StoreOps}; use crate::store::document::entity::Parameters; use crate::store::error::StoreError; +use crate::store::system::migration::Migrate; use crate::store::types::Clover; use chrono::Utc; use rocksdb::{IteratorMode, TransactionDB}; @@ -25,6 +26,7 @@ use std::str::FromStr; use std::sync::Arc; pub mod entity; +pub mod cleanup; pub const FAMILY_NAME: &'static str = "document"; @@ -180,6 +182,8 @@ impl Repository { } } +impl Migrate for Repository {} + impl Clone for Repository { fn clone(&self) -> Self { Self { diff --git a/src/store/error.rs b/src/store/error.rs index 85da3aa..bb368c5 100644 --- a/src/store/error.rs +++ b/src/store/error.rs @@ -18,6 +18,7 @@ use std::fmt::Display; use std::fmt::Formatter; +#[derive(Debug)] pub enum StoreError { CommitFailure(String), RollbackFailure(String), diff --git a/src/store/internal_user/entity.rs b/src/store/internal_user/entity.rs index 81ea473..20539a7 100644 --- a/src/store/internal_user/entity.rs +++ b/src/store/internal_user/entity.rs @@ -93,6 +93,10 @@ impl Parameters { false }) } + + pub fn has_any_permission(&self, permission: PermissionFlag) -> bool { + self.permissions.contains_any(&permission) + } } impl Document for Parameters { diff --git a/src/store/internal_user/mod.rs b/src/store/internal_user/mod.rs index 3c470fd..82fcbf8 100644 --- a/src/store/internal_user/mod.rs +++ b/src/store/internal_user/mod.rs @@ -17,9 +17,13 @@ */ pub mod entity; +use crate::security::permission::PermissionFlag; use crate::store::db::{Store, StoreOps}; use crate::store::error::StoreError; use crate::store::internal_user::entity::{Parameters, Username}; +use crate::store::system::migration::Migrate; +use crate::store::system::SchemaVersion; +use crate::{deny_latest_schema, panic_latest_schema}; use rocksdb::{IteratorMode, TransactionDB}; use std::sync::Arc; @@ -91,6 +95,36 @@ impl Repository { } } +impl Migrate for Repository { + fn migrate(&self, from_version: SchemaVersion) -> Result<(), StoreError> { + deny_latest_schema!(from_version); + + match from_version { + SchemaVersion::Initial => { + let tx = self.store.transaction()?; + let it = tx.iterator(IteratorMode::Start)?; + + for e in it { + match e { + Ok((key, mut params)) => { + if params.has_any_permission(PermissionFlag::All) { + params.permissions |= PermissionFlag::BypassRateLimit; + tx.store(key.as_str(), params.as_ref())?; + } + } + _ => {} + } + } + + tx.commit()?; + } + SchemaVersion::V1 => panic_latest_schema!(), + } + + Ok(()) + } +} + impl Clone for Repository { fn clone(&self) -> Self { Self { diff --git a/src/store/mod.rs b/src/store/mod.rs index 16822aa..f343f63 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -59,6 +59,7 @@ pub struct Config { pub compress: bool, } +#[derive(Debug)] pub struct DatabaseInitError(String); impl Display for DatabaseInitError { diff --git a/src/store/system/error.rs b/src/store/system/migration.rs similarity index 61% rename from src/store/system/error.rs rename to src/store/system/migration.rs index 211ba19..8a5adc0 100644 --- a/src/store/system/error.rs +++ b/src/store/system/migration.rs @@ -15,23 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +use crate::deny_latest_schema; +use crate::panic_latest_schema; use crate::store::error::StoreError; -use std::fmt::{Display, Formatter}; +use crate::store::system::SchemaVersion; -pub enum SystemStoreError { - UnknownSchemaVersion(u64), - GenericStoreError(StoreError), -} - -impl Display for SystemStoreError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - SystemStoreError::UnknownSchemaVersion(version) => { - f.write_fmt(format_args!("unknown system schema version: {}", version)) - } - SystemStoreError::GenericStoreError(e) => { - f.write_fmt(format_args!("store error: {}", e)) - } - } +pub trait Migrate: Sized { + fn migrate(&self, from_version: SchemaVersion) -> Result<(), StoreError> { + deny_latest_schema!(from_version); + Ok(()) } } diff --git a/src/store/system/mod.rs b/src/store/system/mod.rs index 3e20dd1..55fb484 100644 --- a/src/store/system/mod.rs +++ b/src/store/system/mod.rs @@ -15,59 +15,66 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -pub mod error; +pub mod migration; +use std::fmt::{Display, Formatter}; use crate::store::db::RawStore; use crate::store::error::StoreError; -use crate::store::system::error::SystemStoreError; use byteorder::{BigEndian, ByteOrder}; +use rand::rngs::ThreadRng; +use rand::Rng; use rocksdb::TransactionDB; use std::sync::Arc; -use rand::Rng; -use rand::rngs::ThreadRng; pub const FAMILY_NAME: &'static str = "safe_house"; const SCHEMA_VERSION_KEY: &'static str = "schema_version"; const JWT_SECRET_KEY: &'static str = "jwt_secret"; -const SCHEMA_VERSION: u64 = 0; - +#[derive(Clone)] pub struct Repository { store: RawStore, } impl Repository { - pub fn new(db: &Arc) -> Result { + pub fn new(db: &Arc) -> Self { let store = RawStore::new(db, FAMILY_NAME); - let repository = Self { store }; - - if let Some(version) = repository - .schema_version() - .map_err(|e| SystemStoreError::GenericStoreError(e))? - { - if version != SCHEMA_VERSION { - return Err(SystemStoreError::UnknownSchemaVersion(version)); + Self { store } + } + + pub fn check_migration(&self) -> Result { + Ok(match self.schema_version()? { + Some(version) => { + if version == SchemaVersion::LATEST { + MigrationResult::UpToDate(version) + } else if version < SchemaVersion::LATEST { + MigrationResult::Required(version) + } else { + MigrationResult::UnknownVersion(version) + } } - } else { - repository - .set_schema_version(SCHEMA_VERSION) - .map_err(|e| SystemStoreError::GenericStoreError(e))?; - } + None => { + self.complete_migration()?; + MigrationResult::UpToDate(SchemaVersion::LATEST) + } + }) + } - Ok(repository) + pub fn complete_migration(&self) -> Result { + self.set_schema_version(SchemaVersion::LATEST)?; + Ok(SchemaVersion::LATEST) } - pub fn schema_version(&self) -> Result, StoreError> { + pub fn schema_version(&self) -> Result, StoreError> { Ok(self .store .load(SCHEMA_VERSION_KEY)? - .map(|encoded| BigEndian::read_u64(encoded.as_ref()))) + .and_then(|encoded| SchemaVersion::from_u64(BigEndian::read_u64(encoded.as_ref())))) } - fn set_schema_version(&self, version: u64) -> Result<(), StoreError> { + fn set_schema_version(&self, version: SchemaVersion) -> Result<(), StoreError> { let mut encoded = [0; 8]; - BigEndian::write_u64(encoded.as_mut(), version); + BigEndian::write_u64(encoded.as_mut(), version.to_u64()); self.store.store(SCHEMA_VERSION_KEY, &Vec::from(encoded))?; Ok(()) @@ -101,3 +108,85 @@ impl Repository { Ok(key) } } + +macro_rules! schema_version { + ( @latest $head:ident, ) => { + pub const LATEST: SchemaVersion = Self::$head; + }; + ( @latest $_head:ident, $($tail:ident,)* ) => { + schema_version!(@latest $($tail,)*); + }; + ( $($name:ident = $value:expr),* ) => { + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq)] + pub enum SchemaVersion { + $( + $name, + )* + } + + impl SchemaVersion { + schema_version!(@latest $($name,)*); + + pub const fn from_u64(value: u64) -> Option { + match value { + $( + $value => Some(Self::$name), + )* + _ => None, + } + } + + pub const fn to_u64(&self) -> u64 { + match self { + $( + Self::$name => $value, + )* + } + } + + pub fn is_latest(&self) -> bool { + *self == Self::LATEST + } + } + + impl Display for SchemaVersion { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + $( + Self::$name => f.write_str($value.to_string().as_str()), + )* + } + } + } + } +} + +schema_version!(Initial = 0, V1 = 1); + +pub enum MigrationResult { + /// Indicates that store migration is required before the application can be used. + Required(SchemaVersion), + + /// Indicates that the schema version is unknown and that the application should thus shut down + /// before damaging the store. + UnknownVersion(SchemaVersion), + + /// Indicates that the store is up to date. + UpToDate(SchemaVersion), +} + +#[macro_export] +macro_rules! panic_latest_schema { + () => { + panic!("Migration from latest is unsupported") + }; +} + +#[macro_export] +macro_rules! deny_latest_schema { + ($name:ident) => { + if $name.is_latest() { + panic_latest_schema!() + } + }; +} \ No newline at end of file diff --git a/web/src/api/user.ts b/web/src/api/user.ts index 606f199..1e62fab 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -131,6 +131,7 @@ export class UserClient { } } +// FIXME: TypeScript numbers are limited to 32-bit by default - This system needs to be replaced export enum Permission { ViewDocument = 1 << 0, CreateDocument = 1 << 1, @@ -142,13 +143,15 @@ export enum Permission { EditOwnUser = 1 << 10, EditAnyUser = 1 << 11, DeleteOwnUser = 1 << 12, - DeleteAnyUser = 1 << 13 + DeleteAnyUser = 1 << 13, + + BypassRateLimit = 1 << 24, } export enum PermissionGroup { Admin = Permission.ViewDocument | Permission.ViewUser | Permission.CreateUser | Permission.EditAnyUser | Permission.DeleteAnyUser, User = Permission.CreateDocument | Permission.DeleteOwnDocument | Permission.EditOwnUser | Permission.DeleteOwnUser, - All = PermissionGroup.Admin | PermissionGroup.User | Permission.DeleteAnyDocument + All = PermissionGroup.Admin | PermissionGroup.User | Permission.DeleteAnyDocument | Permission.BypassRateLimit } export type PermissionKey = string; diff --git a/web/src/components/user/PermissionList.vue b/web/src/components/user/PermissionList.vue index f405926..8560ffd 100644 --- a/web/src/components/user/PermissionList.vue +++ b/web/src/components/user/PermissionList.vue @@ -42,22 +42,95 @@ ul li {

document
    -
  • view
  • -
  • create
  • -
  • delete (own)
  • -
  • delete (any)
  • +
  • + view + +
  • +
  • + create + +
  • +
  • + delete (own) + +
  • +
  • + delete (any) + +
user
    -
  • view
  • -
  • create
  • -
  • edit (own)
  • -
  • edit (any)
  • -
  • delete (own)
  • -
  • delete (any)
  • +
  • + view + +
  • +
  • + create + +
  • +
  • + edit (own) + +
  • +
  • + edit (any) + +
  • +
  • + delete (own) + +
  • +
  • + delete (any) + +
  • +
+
+
+
system
+ +
    +
  • + bypass ratelimit + +
diff --git a/web/src/components/user/PermissionMaskPicker.vue b/web/src/components/user/PermissionMaskPicker.vue index d4682c8..76a8b1e 100644 --- a/web/src/components/user/PermissionMaskPicker.vue +++ b/web/src/components/user/PermissionMaskPicker.vue @@ -110,5 +110,18 @@ const model = defineModel(); v-model="model"/> +
+
system
+ +
+ +
+