From 6eb1a8ea60745ad9b2bd4d5dfc185ee598cd313f Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 21:10:55 +0300 Subject: [PATCH 01/43] fix: don't run migrations in debug mode --- src/main.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1b17007..6099b11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,12 +42,16 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - tracing::info!("Running migrations"); - sqlx::migrate!("./migrations").run(app_data.db()).await?; - let port = app_data.port(); let debug = app_data.debug(); + if !debug { + tracing::info!("Running migrations"); + sqlx::migrate!("./migrations").run(app_data.db()).await?; + } else { + tracing::info!("Running in debug mode, skipping migrations"); + } + tracing::info!("Starting server on 0.0.0.0:{}", port); let server = HttpServer::new(move || { let openapi = ApiDoc::openapi(); From 1181bb56013f48f9e5a644d47680527821b3fe63 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 23:15:20 +0300 Subject: [PATCH 02/43] feat(users): add migrations for developer_login_info --- ...14181725_create_developer_login_info_table.down.sql | 3 +++ ...0714181725_create_developer_login_info_table.up.sql | 10 ++++++++++ 2 files changed, 13 insertions(+) create mode 100644 migrations/20260714181725_create_developer_login_info_table.down.sql create mode 100644 migrations/20260714181725_create_developer_login_info_table.up.sql diff --git a/migrations/20260714181725_create_developer_login_info_table.down.sql b/migrations/20260714181725_create_developer_login_info_table.down.sql new file mode 100644 index 0000000..49339f5 --- /dev/null +++ b/migrations/20260714181725_create_developer_login_info_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS developer_login_info; diff --git a/migrations/20260714181725_create_developer_login_info_table.up.sql b/migrations/20260714181725_create_developer_login_info_table.up.sql new file mode 100644 index 0000000..d07a359 --- /dev/null +++ b/migrations/20260714181725_create_developer_login_info_table.up.sql @@ -0,0 +1,10 @@ +-- Add up migration script here + +CREATE TABLE IF NOT EXISTS developer_login_info ( + id BIGINT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + email_verified_at DATETIMETZ, + password TEXT NOT NULL, + FOREIGN KEY (id) REFERENCES developers(id) ON DELETE CASCADE +); + From a0f885aa9e014bb44d437891e06944ffab0c03fe Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 23:15:40 +0300 Subject: [PATCH 03/43] feat: add URL signing and checking --- Cargo.lock | 10 ++++++- Cargo.toml | 4 ++- src/main.rs | 1 + src/urlsign.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/urlsign.rs diff --git a/Cargo.lock b/Cargo.lock index d32181a..2fc14d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1ec0cfec728a79a5109075543131387f911cb4d07716436d7ae20533657a96" + [[package]] name = "convert_case" version = "0.10.0" @@ -1508,6 +1514,7 @@ dependencies = [ "bytes", "chrono", "clap", + "constant_time_eq 0.5.0", "dotenvy", "futures", "image", @@ -1526,6 +1533,7 @@ dependencies = [ "tracing", "tracing-actix-web", "tracing-subscriber", + "url", "urlencoding", "utoipa", "utoipa-swagger-ui", @@ -5057,7 +5065,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "aes", "bzip2", - "constant_time_eq", + "constant_time_eq 0.4.2", "crc32fast", "deflate64", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 8c549a8..17e3532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.0" bytes = "1.11" chrono = { version = "0.4", features = ["default", "serde"] } clap = { version = "4.5", features = ["derive"] } +constant_time_eq = "0.5.0" dotenvy = "0.15" futures = "0.3" image = { version = "0.25", features = ["png", "jpeg", "webp"] } @@ -40,6 +41,7 @@ tokio = { version = "1.44", features = ["rt", "macros", "rt-multi-thread"] } tracing = "0.1" tracing-actix-web = "0.7" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +url = "2.5.8" urlencoding = "2.1.3" utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } @@ -48,4 +50,4 @@ validator = { version = "0.20.0", features = ["derive"] } zip = "8.6.0" [target.'cfg(target_env = "musl")'.dependencies] -mimalloc = "0.1.52" \ No newline at end of file +mimalloc = "0.1.52" diff --git a/src/main.rs b/src/main.rs index 6099b11..ada2e86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod mod_zip; mod openapi; mod storage; mod types; +mod urlsign; mod webhook; // Avoid musl's default allocator due to lackluster performance diff --git a/src/urlsign.rs b/src/urlsign.rs new file mode 100644 index 0000000..990cc5a --- /dev/null +++ b/src/urlsign.rs @@ -0,0 +1,73 @@ +use url::Url; + +/// Add an `exp` query param to the passed URL. +pub fn set_url_exp(url: &mut Url, exp: u64) { + url.query_pairs_mut() + .append_pair("exp", &format!("{}", exp)); +} + +/// Returns a signed URL creeated using the passed URL. +/// +/// The returned URL received 2 fields: +/// - `s`, the signature of the URL +/// +/// If the passed URL contains `s` as a query param, it will be discarded and will +/// not be added to the final hash. +/// +/// # Example +/// +/// ```rust +/// let url = Url::parse("https://example.com?test=1"); +/// +/// let signed = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed without an expiration +/// +/// urlsign::set_url_exp(url, 1691084580); +/// let signed_with_exp = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed with expiration date +/// ``` +pub fn sign_url(mut url: Url, salt: &str) -> Url { + let signed = do_sign_url(&mut url, salt); + url.query_pairs_mut().append_pair("s", &signed); + + url +} + +/// Check if an URL has a valid signarture. +/// +/// The check happens on the `s` query param. If the param is missing, `false` is returned. +// +/// Should be safe against timing attacks, the check uses `constant_time_eq`. +/// +/// # Example +/// +/// ```rust +/// let url = Url::parse("https://example.com?test=1"); +/// +/// let signed = urlsign::sign_url(url, "Wmfd2893gb7"); +/// +/// let valid = urlsign::check_signed_url(signed, "Wmfd2893gb7"); // should be `true` +/// ``` +pub fn check_signed_url(url: &Url, salt: &str) -> bool { + let existing_signature = match url.query_pairs().find(|p| p.0 == "s") { + Some(found) => found.1.to_string(), + None => return false, + }; + + let mut clone = url.clone(); + + let signature = do_sign_url(&mut clone, salt); + + constant_time_eq::constant_time_eq((&existing_signature).as_bytes(), &(signature).as_bytes()) +} + +fn do_sign_url(url: &mut Url, salt: &str) -> String { + let clone = url.clone(); + let new_query: Vec<(_, _)> = clone.query_pairs().filter(|p| p.0 != "s").collect(); + + url.set_query(None); + for pair in new_query { + url.query_pairs_mut() + .append_pair(&pair.0.to_string()[..], &pair.1.to_string()[..]); + } + + sha256::digest(format!("{}{}", salt, url)) +} From 73cdbe8038805cefdf6f049238f76f49bfafaf1b Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 23:40:51 +0300 Subject: [PATCH 04/43] fix: fix urlsign doc examples --- src/urlsign.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/urlsign.rs b/src/urlsign.rs index 990cc5a..a9e37ae 100644 --- a/src/urlsign.rs +++ b/src/urlsign.rs @@ -17,11 +17,11 @@ pub fn set_url_exp(url: &mut Url, exp: u64) { /// # Example /// /// ```rust -/// let url = Url::parse("https://example.com?test=1"); +/// let mut url = Url::parse("https://example.com?test=1").unwrap(); /// /// let signed = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed without an expiration /// -/// urlsign::set_url_exp(url, 1691084580); +/// urlsign::set_url_exp(&mut url, 1691084580); /// let signed_with_exp = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed with expiration date /// ``` pub fn sign_url(mut url: Url, salt: &str) -> Url { @@ -40,11 +40,13 @@ pub fn sign_url(mut url: Url, salt: &str) -> Url { /// # Example /// /// ```rust -/// let url = Url::parse("https://example.com?test=1"); +/// let url = Url::parse("https://example.com?test=1").unwrap(); /// /// let signed = urlsign::sign_url(url, "Wmfd2893gb7"); /// -/// let valid = urlsign::check_signed_url(signed, "Wmfd2893gb7"); // should be `true` +/// let valid = urlsign::check_signed_url(signed, "Wmfd2893gb7"); +/// +/// assert_eq!(valid, true); /// ``` pub fn check_signed_url(url: &Url, salt: &str) -> bool { let existing_signature = match url.query_pairs().find(|p| p.0 == "s") { From d8ff083d29ff9d9cc8aed7a7c1fe3b0ba2df35e9 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 23:41:33 +0300 Subject: [PATCH 05/43] fix: split up query param tuples in urlsign --- src/urlsign.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/urlsign.rs b/src/urlsign.rs index a9e37ae..d2022f9 100644 --- a/src/urlsign.rs +++ b/src/urlsign.rs @@ -49,8 +49,8 @@ pub fn sign_url(mut url: Url, salt: &str) -> Url { /// assert_eq!(valid, true); /// ``` pub fn check_signed_url(url: &Url, salt: &str) -> bool { - let existing_signature = match url.query_pairs().find(|p| p.0 == "s") { - Some(found) => found.1.to_string(), + let existing_signature = match url.query_pairs().find(|(k, _)| k == "s") { + Some((_, value)) => value.to_string(), None => return false, }; @@ -63,12 +63,12 @@ pub fn check_signed_url(url: &Url, salt: &str) -> bool { fn do_sign_url(url: &mut Url, salt: &str) -> String { let clone = url.clone(); - let new_query: Vec<(_, _)> = clone.query_pairs().filter(|p| p.0 != "s").collect(); + let new_query: Vec<(_, _)> = clone.query_pairs().filter(|(k, _)| k != "s").collect(); url.set_query(None); - for pair in new_query { + for (k, v) in new_query { url.query_pairs_mut() - .append_pair(&pair.0.to_string()[..], &pair.1.to_string()[..]); + .append_pair(&k.to_string()[..], &v.to_string()[..]); } sha256::digest(format!("{}{}", salt, url)) From b05260743aa4cfd1e778d15d3c09dfc84fad4915 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 14 Jul 2026 23:42:00 +0300 Subject: [PATCH 06/43] test: add tests for urlsign --- src/urlsign.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/urlsign.rs b/src/urlsign.rs index d2022f9..f6d33b1 100644 --- a/src/urlsign.rs +++ b/src/urlsign.rs @@ -73,3 +73,86 @@ fn do_sign_url(url: &mut Url, salt: &str) -> String { sha256::digest(format!("{}{}", salt, url)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signed_url_is_valid() { + let url = Url::parse("https://example.com?test=1").unwrap(); + let signed = sign_url(url, "salt"); + + assert!(check_signed_url(&signed, "salt")); + } + + #[test] + fn wrong_salt_is_rejected() { + let url = Url::parse("https://example.com?test=1").unwrap(); + let signed = sign_url(url, "salt"); + + assert!(!check_signed_url(&signed, "wrong-salt")); + } + + #[test] + fn missing_signature_is_rejected() { + let url = Url::parse("https://example.com?test=1").unwrap(); + + assert!(!check_signed_url(&url, "salt")); + } + + #[test] + fn tampered_query_is_rejected() { + let url = Url::parse("https://example.com?test=1").unwrap(); + let signed = sign_url(url, "salt"); + let existing_sig = signed + .query_pairs() + .find(|(k, _)| k == "s") + .unwrap() + .1 + .to_string(); + + let mut tampered = signed.clone(); + tampered.set_query(Some(&format!("test=2&s={}", existing_sig))); + + assert!(!check_signed_url(&tampered, "salt")); + } + + #[test] + fn tampered_signature_is_rejected() { + let url = Url::parse("https://example.com?test=1").unwrap(); + let signed = sign_url(url, "salt"); + let existing_sig = signed + .query_pairs() + .find(|(k, _)| k == "s") + .unwrap() + .1 + .to_string(); + + let mut tampered = signed.clone(); + let bogus_sig = "0".repeat(existing_sig.len()); + tampered.set_query(Some(&format!("test=1&s={}", bogus_sig))); + + assert!(!check_signed_url(&tampered, "salt")); + } + + #[test] + fn signing_ignores_existing_s_param() { + let url = Url::parse("https://example.com?test=1&s=bogus").unwrap(); + let signed = sign_url(url, "salt"); + + assert!(check_signed_url(&signed, "salt")); + } + + #[test] + fn exp_param_changes_signature() { + let mut url = Url::parse("https://example.com?test=1").unwrap(); + let signed_without_exp = sign_url(url.clone(), "salt"); + + set_url_exp(&mut url, 1691084580); + let signed_with_exp = sign_url(url, "salt"); + + assert_ne!(signed_without_exp, signed_with_exp); + assert!(check_signed_url(&signed_with_exp, "salt")); + } +} From f7fddbfa8a77928436fcaa6606d7cb40e66ef554 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 16 Jul 2026 20:29:38 +0300 Subject: [PATCH 07/43] feat: temp middleware commit --- src/middleware/mod.rs | 0 src/middleware/signed.rs | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/middleware/mod.rs create mode 100644 src/middleware/signed.rs diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/middleware/signed.rs b/src/middleware/signed.rs new file mode 100644 index 0000000..7bfbf61 --- /dev/null +++ b/src/middleware/signed.rs @@ -0,0 +1,58 @@ +use std::future::Ready; + +use actix_web::{ + Error, + dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, +}; +use futures::future; +use futures::future::LocalBoxFuture; + +pub struct SignedUrl { + signing_key: String, +} + +impl SignedUrl { + pub fn new(signing_key: String) -> Self { + Self { signing_key } + } +} + +impl Transform for SignedUrl +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = SignedUrlMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + future::ok(SignedUrlMiddleware { service }) + } +} + +pub struct SignedUrlMiddleware { + service: S, +} + +impl Service for SignedUrlMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let fut = self.service.call(req); + + Box::pin(async move { Ok(fut.await?) }) + } +} From c31cbe41ae85f86752367c45b0d3b46c10555dc1 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Sat, 8 Aug 2026 18:47:52 +0300 Subject: [PATCH 08/43] feat: finish SignedUrl middleware --- Cargo.lock | 3 + Cargo.toml | 3 + src/main.rs | 5 +- src/middleware/mod.rs | 1 + src/middleware/signed.rs | 44 +++++- src/urisign.rs | 292 +++++++++++++++++++++++++++++++++++++++ src/urlsign.rs | 158 --------------------- 7 files changed, 339 insertions(+), 167 deletions(-) create mode 100644 src/urisign.rs delete mode 100644 src/urlsign.rs diff --git a/Cargo.lock b/Cargo.lock index b9a0593..144cef7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,8 @@ dependencies = [ "constant_time_eq 0.5.0", "dotenvy", "futures", + "hex", + "http 0.2.12", "image", "mimalloc", "moka", @@ -1569,6 +1571,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.11.0", "sha256", "sqlx", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index d226ebd..2458528 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ constant_time_eq = "0.5.0" dotenvy = "0.15" futures = "0.3" image = { version = "0.25", features = ["png", "jpeg", "webp"] } +hex = "0.4.3" +http = "0.2" # 0.2 is required for actix-web compat in middleware moka = { version = "0.12.13", features = ["future"] } regex = "1.10" reqwest = { version = "0.13", default-features = false, features = [ @@ -32,6 +34,7 @@ reqwest = { version = "0.13", default-features = false, features = [ semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.11.0" # I know we already use sha256, it pulls sha2 anyway, I added it for urisign sha256 = "1.5" sqlx = { version = "0.9", features = [ "postgres", diff --git a/src/main.rs b/src/main.rs index 4c754d5..c081d45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ -use crate::openapi::ApiDoc; use crate::types::api; +use crate::{middleware::signed::SignedUrl, openapi::ApiDoc}; use actix_cors::Cors; use actix_web::{ App, HttpServer, @@ -19,12 +19,13 @@ mod events; mod extractors; mod jobs; mod logging; +mod middleware; mod mod_zip; mod openapi; mod s3_worker; mod storage; mod types; -mod urlsign; +mod urisign; mod webhook; // Avoid musl's default allocator due to lackluster performance diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index e69de29..41e9259 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod signed; diff --git a/src/middleware/signed.rs b/src/middleware/signed.rs index 7bfbf61..85204b8 100644 --- a/src/middleware/signed.rs +++ b/src/middleware/signed.rs @@ -1,11 +1,14 @@ -use std::future::Ready; +use std::future::{Ready, ready}; use actix_web::{ - Error, + Error, HttpResponse, + body::EitherBody, dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, }; -use futures::future; use futures::future::LocalBoxFuture; +use http::StatusCode; + +use crate::{types::api::ApiResponse, urisign}; pub struct SignedUrl { signing_key: String, @@ -23,19 +26,23 @@ where S::Future: 'static, B: 'static, { - type Response = ServiceResponse; + type Response = ServiceResponse>; type Error = Error; type InitError = (); type Transform = SignedUrlMiddleware; type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - future::ok(SignedUrlMiddleware { service }) + ready(Ok(SignedUrlMiddleware { + service, + signing_key: self.signing_key.clone(), + })) } } pub struct SignedUrlMiddleware { service: S, + signing_key: String, } impl Service for SignedUrlMiddleware @@ -44,15 +51,38 @@ where S::Future: 'static, B: 'static, { - type Response = ServiceResponse; + type Response = ServiceResponse>; type Error = Error; type Future = LocalBoxFuture<'static, Result>; forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { + if let Err(e) = urisign::check_signed_uri(req.uri(), &self.signing_key) { + let response = match e { + urisign::CheckUriError::Expired => { + HttpResponse::build(StatusCode::GONE).json(ApiResponse { + error: format!("{}", e), + payload: "", + }) + } + urisign::CheckUriError::Invalid => { + HttpResponse::build(StatusCode::NOT_FOUND).json(ApiResponse { + error: "Not found".into(), + payload: "", + }) + } + }; + + let service_response = req.into_response(response).map_into_right_body(); + return Box::pin(ready(Ok(service_response))); + } + let fut = self.service.call(req); + Box::pin(async move { + let res = fut.await?; - Box::pin(async move { Ok(fut.await?) }) + Ok(res.map_into_left_body()) + }) } } diff --git a/src/urisign.rs b/src/urisign.rs new file mode 100644 index 0000000..4f1f86d --- /dev/null +++ b/src/urisign.rs @@ -0,0 +1,292 @@ +use chrono::Utc; +use http::{Uri, uri::PathAndQuery}; +use sha2::Digest; +use std::fmt::Write; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum CheckUriError { + #[error("signature has expired")] + Expired, + #[error("signature is invalid")] + Invalid, +} + +/// Returns a signed URI creeated using the passed URI. +/// +/// The returned URI contains a new `s` query param, containing +/// the signature of the URI. +/// +/// If the passed URL contains `s` as a query param, it will be discarded and will +/// not be added to the final hash. +/// +/// You can optionally offer a UNIX timestamp expiration for +/// the signed URI, which will be added to the query params as `exp`. +/// +/// IMPORTANT: query params named `s` or `exp` WILL BE REMOVED from the URI. +/// +/// # Example +/// +/// ```rust +/// use http::Uri; +/// +/// let uri = Uri::try_from("/path?test=1").unwrap(); +/// let signed = urisign::sign_uri(uri, "Wmfd2893gb7", None); // signed without an expiration +/// +/// let uri = Uri::try_from("/path?test=1").unwrap(); +/// let signed_with_exp = urisign::sign_uri(uri, "Wmfd2893gb7", Some(1691084580)); // signed with expiration date +/// ``` +pub fn sign_uri(uri: Uri, salt: &str, exp: Option) -> Uri { + let mut parts = uri.into_parts(); + + let path_and_query = parts + .path_and_query + .unwrap_or(PathAndQuery::from_static("")); + + let query: String = path_and_query.query().unwrap_or_default().into(); + + let (_, mut query) = strip_param(&query, "exp"); + + if let Some(exp) = exp { + let prefix = if query.is_empty() { "" } else { "&" }; + // This shouldn't fail (hopefully!) + let _ = write!(&mut query, "{}exp={}", prefix, exp); + } + + let (_, query) = strip_param(&query, "s"); + + let signature = compute_signature(salt, path_and_query.path(), &query); + + let signature_prefix = if query.is_empty() { "" } else { "&" }; + let path_and_query = PathAndQuery::try_from(format!( + "{}?{}{}s={}", + path_and_query.path(), + query, + signature_prefix, + signature + )) + .expect("rebuilt query is always valid"); + + parts.path_and_query = Some(path_and_query); + + Uri::from_parts(parts).expect("rebuilt Uri should be valid") +} + +/// Check if an URI has a valid signarture. +/// +/// The check happens on the `s` query param. If the param is missing, or the +/// signature doesn't match, `Err(CheckUriError::Invalid)` is returned. If the +/// `exp` param is present and in the past, `Err(CheckUriError::Expired)` is +/// returned instead. +/// +/// Should be safe against timing attacks, the check uses `constant_time_eq`. +/// +/// # Example +/// +/// ```rust +/// use http::Uri; +/// +/// let uri = Uri::try_from("/path?test=1").unwrap(); +/// +/// let signed = urisign::sign_uri(uri, "Wmfd2893gb7", None); +/// +/// let valid = urisign::check_signed_uri(&signed, "Wmfd2893gb7"); +/// +/// assert!(valid.is_ok()); +/// ``` +pub fn check_signed_uri(uri: &Uri, salt: &str) -> Result<(), CheckUriError> { + let query: String = uri.query().unwrap_or_default().into(); + + let (signature, query) = strip_param(&query, "s"); + + if signature.is_none() { + return Err(CheckUriError::Invalid); + } + + let exp: Option> = + find_param(&query, "exp").map(|exp| exp.parse()); + + if let Some(exp) = exp { + let now = Utc::now().timestamp(); + match exp { + Ok(exp) => { + if exp < now { + return Err(CheckUriError::Expired); + } + } + Err(_) => { + return Err(CheckUriError::Invalid); + } + } + } + + let signature = signature.unwrap(); + let computed = compute_signature(salt, uri.path(), &query); + + if constant_time_eq::constant_time_eq(signature.as_bytes(), computed.as_bytes()) { + Ok(()) + } else { + Err(CheckUriError::Invalid) + } +} + +fn compute_signature(salt: &str, path: &str, query_without_s: &str) -> String { + let mut hash = sha2::Sha256::new(); + + hash.update(salt.as_bytes()); + hash.update(path.as_bytes()); + hash.update(b"?"); + hash.update(query_without_s.as_bytes()); + + hex::encode(hash.finalize()) +} + +fn find_param<'a>(query: &'a str, key: &str) -> Option<&'a str> { + for component in query.split("&") { + let maybe_stripped = component + .strip_prefix(key) + .and_then(|rest| rest.strip_prefix("=")); + + if let Some(stripped) = maybe_stripped { + return Some(stripped); + } + } + + None +} + +fn strip_param<'a>(query: &'a str, key: &str) -> (Option<&'a str>, String) { + let mut ret = String::with_capacity(query.len()); + + let mut found: Option<&str> = None; + + for component in query.split("&") { + let maybe_stripped = component + .strip_prefix(key) + .and_then(|rest| rest.strip_prefix("=")); + + if let Some(stripped) = maybe_stripped { + found = Some(stripped); + } else { + ret.push_str(component); + ret.push('&'); + } + } + + if !ret.is_empty() { + ret.truncate(ret.trim_end_matches("&").len()); + } + + (found, ret) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signed_uri_is_valid() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let signed = sign_uri(uri, "salt", None); + + assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); + } + + #[test] + fn wrong_salt_is_rejected() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let signed = sign_uri(uri, "salt", None); + + assert_eq!( + check_signed_uri(&signed, "wrong-salt"), + Err(CheckUriError::Invalid) + ); + } + + #[test] + fn missing_signature_is_rejected() { + let uri: Uri = "/path?test=1".parse().unwrap(); + + assert_eq!(check_signed_uri(&uri, "salt"), Err(CheckUriError::Invalid)); + } + + #[test] + fn tampered_query_is_rejected() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let signed = sign_uri(uri, "salt", None); + let existing_sig = find_param(signed.query().unwrap(), "s").unwrap(); + + let tampered: Uri = format!("/path?test=2&s={}", existing_sig).parse().unwrap(); + + assert_eq!( + check_signed_uri(&tampered, "salt"), + Err(CheckUriError::Invalid) + ); + } + + #[test] + fn tampered_signature_is_rejected() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let signed = sign_uri(uri, "salt", None); + let existing_sig = find_param(signed.query().unwrap(), "s").unwrap(); + + let bogus_sig = "0".repeat(existing_sig.len()); + let tampered: Uri = format!("/path?test=1&s={}", bogus_sig).parse().unwrap(); + + assert_eq!( + check_signed_uri(&tampered, "salt"), + Err(CheckUriError::Invalid) + ); + } + + #[test] + fn signing_ignores_existing_s_param() { + let uri: Uri = "/path?test=1&s=bogus".parse().unwrap(); + let signed = sign_uri(uri, "salt", None); + + assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); + } + + #[test] + fn exp_param_changes_signature() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let future = Utc::now().timestamp() as u64 + 3600; + + let signed_without_exp = sign_uri(uri.clone(), "salt", None); + let signed_with_exp = sign_uri(uri, "salt", Some(future)); + + assert_ne!(signed_without_exp, signed_with_exp); + assert_eq!(check_signed_uri(&signed_with_exp, "salt"), Ok(())); + } + + #[test] + fn future_exp_is_valid() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let future = Utc::now().timestamp() as u64 + 3600; + + let signed = sign_uri(uri, "salt", Some(future)); + + assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); + } + + #[test] + fn expired_uri_is_rejected() { + let uri: Uri = "/path?test=1".parse().unwrap(); + let past = Utc::now().timestamp() as u64 - 3600; + + let signed = sign_uri(uri, "salt", Some(past)); + + assert_eq!(check_signed_uri(&signed, "salt"), Err(CheckUriError::Expired)); + } + + #[test] + fn malformed_exp_is_rejected() { + // Build a URI whose signature is genuinely valid for its own + // (malformed) `exp`, so this isolates the parse-failure path + // from a plain signature mismatch. + let query = "test=1&exp=not-a-number"; + let signature = compute_signature("salt", "/path", query); + let uri: Uri = format!("/path?{}&s={}", query, signature).parse().unwrap(); + + assert_eq!(check_signed_uri(&uri, "salt"), Err(CheckUriError::Invalid)); + } +} diff --git a/src/urlsign.rs b/src/urlsign.rs deleted file mode 100644 index f6d33b1..0000000 --- a/src/urlsign.rs +++ /dev/null @@ -1,158 +0,0 @@ -use url::Url; - -/// Add an `exp` query param to the passed URL. -pub fn set_url_exp(url: &mut Url, exp: u64) { - url.query_pairs_mut() - .append_pair("exp", &format!("{}", exp)); -} - -/// Returns a signed URL creeated using the passed URL. -/// -/// The returned URL received 2 fields: -/// - `s`, the signature of the URL -/// -/// If the passed URL contains `s` as a query param, it will be discarded and will -/// not be added to the final hash. -/// -/// # Example -/// -/// ```rust -/// let mut url = Url::parse("https://example.com?test=1").unwrap(); -/// -/// let signed = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed without an expiration -/// -/// urlsign::set_url_exp(&mut url, 1691084580); -/// let signed_with_exp = urlsign::sign_url(url.clone(), "Wmfd2893gb7"); // signed with expiration date -/// ``` -pub fn sign_url(mut url: Url, salt: &str) -> Url { - let signed = do_sign_url(&mut url, salt); - url.query_pairs_mut().append_pair("s", &signed); - - url -} - -/// Check if an URL has a valid signarture. -/// -/// The check happens on the `s` query param. If the param is missing, `false` is returned. -// -/// Should be safe against timing attacks, the check uses `constant_time_eq`. -/// -/// # Example -/// -/// ```rust -/// let url = Url::parse("https://example.com?test=1").unwrap(); -/// -/// let signed = urlsign::sign_url(url, "Wmfd2893gb7"); -/// -/// let valid = urlsign::check_signed_url(signed, "Wmfd2893gb7"); -/// -/// assert_eq!(valid, true); -/// ``` -pub fn check_signed_url(url: &Url, salt: &str) -> bool { - let existing_signature = match url.query_pairs().find(|(k, _)| k == "s") { - Some((_, value)) => value.to_string(), - None => return false, - }; - - let mut clone = url.clone(); - - let signature = do_sign_url(&mut clone, salt); - - constant_time_eq::constant_time_eq((&existing_signature).as_bytes(), &(signature).as_bytes()) -} - -fn do_sign_url(url: &mut Url, salt: &str) -> String { - let clone = url.clone(); - let new_query: Vec<(_, _)> = clone.query_pairs().filter(|(k, _)| k != "s").collect(); - - url.set_query(None); - for (k, v) in new_query { - url.query_pairs_mut() - .append_pair(&k.to_string()[..], &v.to_string()[..]); - } - - sha256::digest(format!("{}{}", salt, url)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn signed_url_is_valid() { - let url = Url::parse("https://example.com?test=1").unwrap(); - let signed = sign_url(url, "salt"); - - assert!(check_signed_url(&signed, "salt")); - } - - #[test] - fn wrong_salt_is_rejected() { - let url = Url::parse("https://example.com?test=1").unwrap(); - let signed = sign_url(url, "salt"); - - assert!(!check_signed_url(&signed, "wrong-salt")); - } - - #[test] - fn missing_signature_is_rejected() { - let url = Url::parse("https://example.com?test=1").unwrap(); - - assert!(!check_signed_url(&url, "salt")); - } - - #[test] - fn tampered_query_is_rejected() { - let url = Url::parse("https://example.com?test=1").unwrap(); - let signed = sign_url(url, "salt"); - let existing_sig = signed - .query_pairs() - .find(|(k, _)| k == "s") - .unwrap() - .1 - .to_string(); - - let mut tampered = signed.clone(); - tampered.set_query(Some(&format!("test=2&s={}", existing_sig))); - - assert!(!check_signed_url(&tampered, "salt")); - } - - #[test] - fn tampered_signature_is_rejected() { - let url = Url::parse("https://example.com?test=1").unwrap(); - let signed = sign_url(url, "salt"); - let existing_sig = signed - .query_pairs() - .find(|(k, _)| k == "s") - .unwrap() - .1 - .to_string(); - - let mut tampered = signed.clone(); - let bogus_sig = "0".repeat(existing_sig.len()); - tampered.set_query(Some(&format!("test=1&s={}", bogus_sig))); - - assert!(!check_signed_url(&tampered, "salt")); - } - - #[test] - fn signing_ignores_existing_s_param() { - let url = Url::parse("https://example.com?test=1&s=bogus").unwrap(); - let signed = sign_url(url, "salt"); - - assert!(check_signed_url(&signed, "salt")); - } - - #[test] - fn exp_param_changes_signature() { - let mut url = Url::parse("https://example.com?test=1").unwrap(); - let signed_without_exp = sign_url(url.clone(), "salt"); - - set_url_exp(&mut url, 1691084580); - let signed_with_exp = sign_url(url, "salt"); - - assert_ne!(signed_without_exp, signed_with_exp); - assert!(check_signed_url(&signed_with_exp, "salt")); - } -} From e01329ff51fb2642d19500f8240941ab2a231327 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 10 Aug 2026 21:33:22 +0300 Subject: [PATCH 09/43] feat: add email blocklist and mailer --- Cargo.lock | 130 ++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 11 +++- src/config.rs | 22 +++++++ src/email/blocklist.rs | 98 +++++++++++++++++++++++++++++++ src/email/lettre.rs | 67 +++++++++++++++++++++ src/email/mailer.rs | 44 ++++++++++++++ src/email/mod.rs | 86 +++++++++++++++++++++++++++ src/main.rs | 3 +- 8 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 src/email/blocklist.rs create mode 100644 src/email/lettre.rs create mode 100644 src/email/mailer.rs create mode 100644 src/email/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 144cef7..c8abda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64", + "base64 0.22.1", "bitflags", "brotli", "bytes", @@ -423,6 +423,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -493,7 +505,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.2", "log", "rustls", @@ -607,6 +619,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit_field" version = "0.10.3" @@ -631,6 +655,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1266,6 +1299,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1554,6 +1603,7 @@ dependencies = [ "actix-web", "ammonia", "anyhow", + "argon2", "bytes", "chrono", "clap", @@ -1563,6 +1613,7 @@ dependencies = [ "hex", "http 0.2.12", "image", + "lettre", "mimalloc", "moka", "regex", @@ -1766,6 +1817,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -1890,7 +1952,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2239,6 +2301,33 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.5", + "tokio", + "tokio-rustls", + "url", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -2696,6 +2785,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3020,6 +3120,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3063,6 +3169,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -3222,7 +3334,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -3263,7 +3375,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "h2 0.4.15", @@ -3369,7 +3481,7 @@ dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64", + "base64 0.22.1", "bytes", "cfg-if", "futures-util", @@ -3799,7 +3911,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", @@ -3904,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -4538,7 +4650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "actix-web", - "base64", + "base64 0.22.1", "mime_guess", "regex", "rust-embed", diff --git a/Cargo.toml b/Cargo.toml index 2458528..78a774b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,15 +13,24 @@ actix-multipart = "0.8" actix-web = "4.10" ammonia = "4" anyhow = "1.0" +argon2 = "0.5.3" bytes = "1.11" chrono = { version = "0.4", features = ["default", "serde"] } clap = { version = "4.5", features = ["derive"] } constant_time_eq = "0.5.0" dotenvy = "0.15" futures = "0.3" -image = { version = "0.25", features = ["png", "jpeg", "webp"] } hex = "0.4.3" http = "0.2" # 0.2 is required for actix-web compat in middleware +image = { version = "0.25", features = ["png", "jpeg", "webp"] } +lettre = { version = "0.11.23", default-features = false, features = [ + "builder", + "hostname", + "smtp-transport", + "pool", + "tokio1-rustls", + "ring", +] } moka = { version = "0.12.13", features = ["future"] } regex = "1.10" reqwest = { version = "0.13", default-features = false, features = [ diff --git a/src/config.rs b/src/config.rs index 2fe8938..e528622 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,6 @@ use std::{ + path::PathBuf, + str::FromStr, sync::{Arc, OnceLock}, time::Duration, }; @@ -8,6 +10,7 @@ use tokio::sync::mpsc::Sender; use crate::{ dns::ValidateDnsResolver, + email::{SmtpConfig, blocklist::Blocklist, lettre::LettreBackend, mailer::Mailer}, endpoints::mods::IndexQueryParams, s3_worker::S3WorkerTask, storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration}, @@ -37,6 +40,8 @@ pub struct AppData { mods_cache: Cache>>, http_client: reqwest::Client, check_dns_http_client: reqwest::Client, + mailer: Option, + email_blocklist: Option, s3_sender: OnceLock>, } @@ -94,6 +99,17 @@ pub async fn build_config() -> anyhow::Result { .timeout(Duration::from_secs(30)) .build()?; + let smtp_config = SmtpConfig::from_env()?; + + let blocklist = if smtp_config.is_some() { + let env_var = dotenvy::var("EMAIL_BLOCKLIST_PATH").unwrap_or("./blocklist.conf".into()); + let path = PathBuf::from_str(&env_var).ok(); + + path.map(|path| Blocklist::load(path)) + } else { + None + }; + Ok(AppData { db: pool, app_url: app_url.clone(), @@ -125,6 +141,8 @@ pub async fn build_config() -> anyhow::Result { .read_timeout(Duration::from_secs(30)) .build()?, check_dns_http_client, + mailer: smtp_config.map(|config| Mailer::new(Arc::new(LettreBackend::new(&config)?))), + email_blocklist: blocklist, s3_sender: OnceLock::new(), }) } @@ -204,6 +222,10 @@ impl AppData { &self.http_client } + pub fn mailer(&self) -> Option<&Mailer> { + self.mailer.as_ref() + } + /// Client that validates passed host, denies all private resolved IP addresses. /// Useful for preventing Server Side Request Forgery. /// diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs new file mode 100644 index 0000000..6e822a9 --- /dev/null +++ b/src/email/blocklist.rs @@ -0,0 +1,98 @@ +use std::{ + collections::HashSet, + fs::File, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use crate::email::EmailAddress; + +const MAX_BLOCKLIST_SIZE: u64 = 10 * 1024 * 1024; // 10 MB + +#[derive(Debug, thiserror::Error)] +pub enum BlocklistError { + #[error("blocked domain")] + BlockedDomain, +} + +pub struct CheckedRecipient(EmailAddress); + +pub fn validate_against_blocklist( + email: EmailAddress, + blocklist: Option, +) -> Result { + match blocklist { + Some(blocklist) => { + if blocklist.is_blocked(&email.domain) { + Err(BlocklistError::BlockedDomain) + } else { + Ok(CheckedRecipient(email)) + } + } + None => Ok(CheckedRecipient(email)), + } +} + +pub struct Blocklist { + entries: Arc>>, + path: Arc, +} + +impl Blocklist { + pub fn load(path: PathBuf) -> Self { + let entries = Self::read_file(&path) + .inspect_err(|e| { + tracing::error!("failed to parse blocklist {path}, using empty blocklist: {e}") + }) + .unwrap_or_default(); + + Self { + entries: Arc::new(RwLock::new(entries)), + path: Arc::new(path), + } + } + + pub fn is_blocked(&self, domain: &str) -> bool { + self.entries + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .contains(domain) + } + + pub fn refresh(&self) { + let entries = Self::read_file(&path).inspect_err(|e| { + tracing::warn!("failed to parse blocklist {path}, skipping blocklist refresh: {e}") + }); + + if let Ok(entries) = entries { + let mut guard = self + .entries + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + *guard = entries; + } + } + + fn read_file(path: &Path) -> anyhow::Result> { + let size = std::fs::metadata(path)?.len(); + + if size > MAX_BLOCKLIST_SIZE { + anyhow::bail!( + "blocklist file {} is {size} bytes, exceeds {MAX_BLOCKLIST_SIZE} byte limit", + path.display() + ) + } + + let content = std::fs::read_to_string(path)?; + + let entries = content + .lines() + .map(str::trim) + .filter(|&line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_lowercase) + .collect(); + + Ok(entries) + } +} diff --git a/src/email/lettre.rs b/src/email/lettre.rs new file mode 100644 index 0000000..ebd91ab --- /dev/null +++ b/src/email/lettre.rs @@ -0,0 +1,67 @@ +use lettre::{ + AsyncSmtpTransport, Message, Tokio1Executor, + message::{Mailbox, MultiPart, SinglePart, header::ContentType}, + transport::smtp::authentication::Credentials, +}; + +use crate::email::{ + SmtpConfig, + mailer::{BoxFuture, EmailBody, MailerBackend, MailerError, OutgoingEmail}, +}; + +pub struct LettreBackend { + transport: AsyncSmtpTransport, + from: Mailbox, +} + +impl LettreBackend { + pub fn new(config: &SmtpConfig) -> anyhow::Result { + let transport = AsyncSmtpTransport::::relay(&config.host)? + .port(config.port) + .credentials(Credentials::new( + config.username.clone(), + config.password.clone(), + )) + .build(); + + Ok(Self { + transport, + from: Mailbox::new(config.from_name.clone(), config.from_address.parse()?), + }) + } +} + +impl MailerBackend for LettreBackend { + fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, ()> { + Box::pin(async move { + let builder = Message::builder() + .from(self.from.clone()) + .to(email + .to + .parse() + .map_err(|_| MailerError::InvalidMessage("invalid recipient address".into()))?) + .subject(&email.subject); + + let message = match &email.body { + EmailBody::Plaintext(text) => { + builder.header(ContentType::TEXT_PLAIN).body(text.clone()) + } + EmailBody::Html(html) => builder.header(ContentType::TEXT_HTML).body(html.clone()), + EmailBody::Multipart { text, html } => builder.multipart( + MultiPart::alternative() + .singlepart(SinglePart::plain(text.clone())) + .singlepart(SinglePart::html(html.clone())), + ), + } + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + + self.transport + .send(message) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| MailerError::Send(e.to_string()))?; + + Ok(()) + }) + } +} diff --git a/src/email/mailer.rs b/src/email/mailer.rs new file mode 100644 index 0000000..33839ff --- /dev/null +++ b/src/email/mailer.rs @@ -0,0 +1,44 @@ +use std::{pin::Pin, sync::Arc}; + +use crate::email::blocklist::CheckedRecipient; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +pub enum MailerError { + #[error("invalid message: {0}")] + InvalidMessage(String), + #[error("failed to send: {0}")] + SendError(String), +} + +pub type MailerResult = Result; + +pub enum EmailBody { + Plaintext(String), + Html(String), + Multipart { text: String, html: String }, +} + +pub struct OutgoingEmail { + pub to: CheckedRecipient, + pub subject: String, + pub body: EmailBody, +} + +pub trait MailerBackend: Send + Sync { + fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, MailerResult<()>>; +} + +pub struct Mailer { + backend: Arc, +} + +impl Mailer { + pub fn new(backend: Arc) -> Self { + Mailer { backend } + } + + pub async fn send(&self, email: &OutgoingEmail) -> MailerReuslt<()> { + self.backend.send(email); + } +} diff --git a/src/email/mod.rs b/src/email/mod.rs new file mode 100644 index 0000000..49215f8 --- /dev/null +++ b/src/email/mod.rs @@ -0,0 +1,86 @@ +use std::fmt::Display; +use std::str::FromStr; + +use validator::ValidateEmail; + +pub mod blocklist; +pub mod lettre; +pub mod mailer; + +#[derive(thiserror::Error)] +pub enum EmailAddressParseError { + #[error("invalid email address")] + InvalidEmail, +} + +pub struct EmailAddress { + local_part: String, + domain: String, +} + +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub from_address: String, + pub from_name: Option, +} + +impl SmtpConfig { + pub fn from_env() -> anyhow::Result> { + const DEFAULT_SMTP_PORT: u16 = 587; + + let enabled = dotenvy::var("SMTP_ENABLED").unwrap_or_default(); + if enabled != "1" { + return Ok(None); + } + + Ok(Some(SmtpConfig { + host: dotenvy::var("SMTP_HOST").ok()?, + port: dotenvy::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_SMTP_PORT), + username: dotenvy::var("SMTP_USERNAME")?, + password: dotenvy::var("SMTP_PASSWORD")?, + from_address: dotenvy::var("SMTP_FROM_ADDRESS")?, + from_name: dotenvy::var("SMTP_FROM_NAME").ok(), + })) + } +} + +impl FromStr for EmailAddress { + type Err = EmailAddressParseError; + + fn from_str(s: &str) -> Result { + if !s.validate_email() { + return Err(EmailAddressParseError::InvalidEmail); + } + + let (local_part, domain) = s + .split_once('@') + .expect("validate_email already confirmed an '@' is present"); + + Ok(EmailAddress { + local_part: local_part.into(), + domain: domain.into(), + }) + } +} + +impl Display for EmailAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.local_part, self.domain) + } +} + +impl From for String { + fn from(value: EmailAddress) -> Self { + let mut s = value.local_part; + s.push('@'); + s.push_str(&value.domain); + + s + } +} diff --git a/src/main.rs b/src/main.rs index c081d45..d18e816 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ +use crate::openapi::ApiDoc; use crate::types::api; -use crate::{middleware::signed::SignedUrl, openapi::ApiDoc}; use actix_cors::Cors; use actix_web::{ App, HttpServer, @@ -14,6 +14,7 @@ mod cli; mod config; mod database; mod dns; +mod email; mod endpoints; mod events; mod extractors; From 08f559a5a47f5649f397917176db5ff8c0ae7388 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 10 Aug 2026 22:01:12 +0300 Subject: [PATCH 10/43] feat: use types to validate email blacklist --- src/email/blocklist.rs | 35 ++++++++++++++++++++++------------- src/email/lettre.rs | 21 +++++++++++++++------ src/email/mailer.rs | 4 ++-- src/email/mod.rs | 10 ++++++++++ 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index 6e822a9..cfedd7a 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -15,21 +15,30 @@ pub enum BlocklistError { BlockedDomain, } -pub struct CheckedRecipient(EmailAddress); - -pub fn validate_against_blocklist( - email: EmailAddress, - blocklist: Option, -) -> Result { - match blocklist { - Some(blocklist) => { - if blocklist.is_blocked(&email.domain) { - Err(BlocklistError::BlockedDomain) - } else { - Ok(CheckedRecipient(email)) +/// This struct just holds an address that has been checked +/// with the blocklist. +#[derive(Clone, Debug)] +pub struct ApprovedEmailAddress(EmailAddress); + +impl ApprovedEmailAddress { + pub fn parse( + email: EmailAddress, + blocklist: Option, + ) -> Result { + match blocklist { + Some(blocklist) => { + if blocklist.is_blocked(&email.domain) { + Err(BlocklistError::BlockedDomain) + } else { + Ok(Self(email)) + } } + None => Ok(Self(email)), } - None => Ok(CheckedRecipient(email)), + } + + pub fn email(&self) -> &EmailAddress { + &self.0 } } diff --git a/src/email/lettre.rs b/src/email/lettre.rs index ebd91ab..1810461 100644 --- a/src/email/lettre.rs +++ b/src/email/lettre.rs @@ -1,11 +1,12 @@ use lettre::{ - AsyncSmtpTransport, Message, Tokio1Executor, + Address, AsyncSmtpTransport, Message, Tokio1Executor, + address::AddressError, message::{Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::authentication::Credentials, }; use crate::email::{ - SmtpConfig, + EmailAddress, SmtpConfig, mailer::{BoxFuture, EmailBody, MailerBackend, MailerError, OutgoingEmail}, }; @@ -34,12 +35,12 @@ impl LettreBackend { impl MailerBackend for LettreBackend { fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, ()> { Box::pin(async move { + let address = Address::try_from(email.to.email().clone()) + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + let builder = Message::builder() .from(self.from.clone()) - .to(email - .to - .parse() - .map_err(|_| MailerError::InvalidMessage("invalid recipient address".into()))?) + .to(Mailbox::new(None, address)) .subject(&email.subject); let message = match &email.body { @@ -65,3 +66,11 @@ impl MailerBackend for LettreBackend { }) } } + +impl TryFrom for Address { + type Error = AddressError; + + fn try_from(value: EmailAddress) -> Result { + Address::new(value.local_part(), value.domain()) + } +} diff --git a/src/email/mailer.rs b/src/email/mailer.rs index 33839ff..e46c1cc 100644 --- a/src/email/mailer.rs +++ b/src/email/mailer.rs @@ -1,6 +1,6 @@ use std::{pin::Pin, sync::Arc}; -use crate::email::blocklist::CheckedRecipient; +use crate::email::blocklist::ApprovedEmailAddress; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -20,7 +20,7 @@ pub enum EmailBody { } pub struct OutgoingEmail { - pub to: CheckedRecipient, + pub to: ApprovedEmailAddress, pub subject: String, pub body: EmailBody, } diff --git a/src/email/mod.rs b/src/email/mod.rs index 49215f8..53d0147 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -13,6 +13,7 @@ pub enum EmailAddressParseError { InvalidEmail, } +#[derive(Clone, Debug)] pub struct EmailAddress { local_part: String, domain: String, @@ -69,6 +70,15 @@ impl FromStr for EmailAddress { } } +impl EmailAddress { + pub fn local_part(&self) -> &str { + &self.local_part + } + pub fn domain(&self) -> &str { + &self.domain + } +} + impl Display for EmailAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.local_part, self.domain) From 237c80a87a424702a10f0a4c1ed4684f975efb4a Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 10 Aug 2026 22:55:56 +0300 Subject: [PATCH 11/43] fix: switch to parking_lot for sync --- Cargo.lock | 1 + Cargo.toml | 1 + src/email/blocklist.rs | 15 +++++---------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8abda4..9d67e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,6 +1616,7 @@ dependencies = [ "lettre", "mimalloc", "moka", + "parking_lot", "regex", "reqwest 0.13.4", "rust-s3", diff --git a/Cargo.toml b/Cargo.toml index 78a774b..19667e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ lettre = { version = "0.11.23", default-features = false, features = [ "ring", ] } moka = { version = "0.12.13", features = ["future"] } +parking_lot = "0.12.5" regex = "1.10" reqwest = { version = "0.13", default-features = false, features = [ "json", diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index cfedd7a..3ff8e5d 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -2,9 +2,11 @@ use std::{ collections::HashSet, fs::File, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::Arc, }; +use parking_lot::RwLock; + use crate::email::EmailAddress; const MAX_BLOCKLIST_SIZE: u64 = 10 * 1024 * 1024; // 10 MB @@ -62,10 +64,7 @@ impl Blocklist { } pub fn is_blocked(&self, domain: &str) -> bool { - self.entries - .read() - .unwrap_or_else(|poison| poison.into_inner()) - .contains(domain) + self.entries.read().contains(domain) } pub fn refresh(&self) { @@ -74,11 +73,7 @@ impl Blocklist { }); if let Ok(entries) = entries { - let mut guard = self - .entries - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - + let mut guard = self.entries.write(); *guard = entries; } } From 1e70746ba25d293e17b99e9815cb2137f2530713 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 10 Aug 2026 23:02:31 +0300 Subject: [PATCH 12/43] feat: add migrations for email setup / change --- ...14181725_create_developer_login_info_table.up.sql | 5 +++-- ...3519_add_unique_to_developer_login_infos.down.sql | 3 +++ ...163519_add_unique_to_developer_login_infos.up.sql | 4 ++++ ...810163645_add_email_setup_requests_table.down.sql | 3 +++ ...60810163645_add_email_setup_requests_table.up.sql | 12 ++++++++++++ ...10163652_add_email_change_requests_table.down.sql | 3 +++ ...0810163652_add_email_change_requests_table.up.sql | 11 +++++++++++ 7 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 migrations/20260810163519_add_unique_to_developer_login_infos.down.sql create mode 100644 migrations/20260810163519_add_unique_to_developer_login_infos.up.sql create mode 100644 migrations/20260810163645_add_email_setup_requests_table.down.sql create mode 100644 migrations/20260810163645_add_email_setup_requests_table.up.sql create mode 100644 migrations/20260810163652_add_email_change_requests_table.down.sql create mode 100644 migrations/20260810163652_add_email_change_requests_table.up.sql diff --git a/migrations/20260714181725_create_developer_login_info_table.up.sql b/migrations/20260714181725_create_developer_login_info_table.up.sql index d07a359..eba23b8 100644 --- a/migrations/20260714181725_create_developer_login_info_table.up.sql +++ b/migrations/20260714181725_create_developer_login_info_table.up.sql @@ -1,10 +1,11 @@ -- Add up migration script here CREATE TABLE IF NOT EXISTS developer_login_info ( - id BIGINT NOT NULL PRIMARY KEY, + developer_id INTEGER NOT NULL PRIMARY KEY, email TEXT NOT NULL, email_verified_at DATETIMETZ, password TEXT NOT NULL, - FOREIGN KEY (id) REFERENCES developers(id) ON DELETE CASCADE + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE ); diff --git a/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql new file mode 100644 index 0000000..a0e9456 --- /dev/null +++ b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP INDEX developer_login_info_email_lower_idx ON developer_login_info; diff --git a/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql b/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql new file mode 100644 index 0000000..3da97c9 --- /dev/null +++ b/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql @@ -0,0 +1,4 @@ +-- Add up migration script here + +CREATE UNIQUE INDEX developer_login_info_email_lower_idx +ON developer_login_info(LOWER(email)); diff --git a/migrations/20260810163645_add_email_setup_requests_table.down.sql b/migrations/20260810163645_add_email_setup_requests_table.down.sql new file mode 100644 index 0000000..26d61c4 --- /dev/null +++ b/migrations/20260810163645_add_email_setup_requests_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE email_setup_requests; diff --git a/migrations/20260810163645_add_email_setup_requests_table.up.sql b/migrations/20260810163645_add_email_setup_requests_table.up.sql new file mode 100644 index 0000000..e6eea62 --- /dev/null +++ b/migrations/20260810163645_add_email_setup_requests_table.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here + +CREATE TABLE email_setup_requests ( + developer_id INTEGER NOT NULL PRIMARY KEY, + token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); diff --git a/migrations/20260810163652_add_email_change_requests_table.down.sql b/migrations/20260810163652_add_email_change_requests_table.down.sql new file mode 100644 index 0000000..5717232 --- /dev/null +++ b/migrations/20260810163652_add_email_change_requests_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE email_change_requests; diff --git a/migrations/20260810163652_add_email_change_requests_table.up.sql b/migrations/20260810163652_add_email_change_requests_table.up.sql new file mode 100644 index 0000000..30ee23c --- /dev/null +++ b/migrations/20260810163652_add_email_change_requests_table.up.sql @@ -0,0 +1,11 @@ +-- Add up migration script here + +CREATE TABLE email_change_requests ( + developer_id INTEGER NOT NULL PRIMARY KEY, + token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + new_email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); From 38f2972e759553a7a3c819fe19af342ee3bcad92 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 17 Aug 2026 23:33:41 +0300 Subject: [PATCH 13/43] feat(email): finish /v1/me/email/setup --- Cargo.lock | 59 ++++++++ Cargo.toml | 4 + ...5_create_developer_login_info_table.up.sql | 2 +- src/config.rs | 16 +- src/database/mod.rs | 2 + .../repository/email_setup_requests.rs | 125 +++++++++++++++ src/database/repository/mod.rs | 1 + src/email/blocklist.rs | 23 +-- src/email/lettre.rs | 33 ++-- src/email/mailer.rs | 20 ++- src/email/mod.rs | 49 +++++- src/endpoints/auth/database.rs | 0 src/endpoints/auth/mod.rs | 1 + src/endpoints/developers.rs | 142 +++++++++++++++++- src/endpoints/mod.rs | 9 ++ src/main.rs | 1 + src/openapi.rs | 1 + 17 files changed, 453 insertions(+), 35 deletions(-) create mode 100644 src/database/repository/email_setup_requests.rs create mode 100644 src/endpoints/auth/database.rs diff --git a/Cargo.lock b/Cargo.lock index 9d67e29..5d94964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1611,12 +1611,15 @@ dependencies = [ "dotenvy", "futures", "hex", + "html2text", "http 0.2.12", "image", "lettre", + "maud", "mimalloc", "moka", "parking_lot", + "password-hash", "regex", "reqwest 0.13.4", "rust-s3", @@ -1829,6 +1832,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "html2text" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8" +dependencies = [ + "html5ever", + "tendril", + "thiserror", + "unicode-width", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -2323,6 +2338,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", + "rustls-platform-verifier", "socket2 0.6.5", "tokio", "tokio-rustls", @@ -2470,6 +2486,28 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -2982,6 +3020,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", +] + [[package]] name = "profiling" version = "1.0.18" @@ -3175,6 +3225,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4577,6 +4630,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 19667e2..c69c3dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ constant_time_eq = "0.5.0" dotenvy = "0.15" futures = "0.3" hex = "0.4.3" +html2text = "0.17.1" http = "0.2" # 0.2 is required for actix-web compat in middleware image = { version = "0.25", features = ["png", "jpeg", "webp"] } lettre = { version = "0.11.23", default-features = false, features = [ @@ -30,9 +31,11 @@ lettre = { version = "0.11.23", default-features = false, features = [ "pool", "tokio1-rustls", "ring", + "rustls-platform-verifier", ] } moka = { version = "0.12.13", features = ["future"] } parking_lot = "0.12.5" +password-hash = { version = "0.5", features = ["getrandom"] } regex = "1.10" reqwest = { version = "0.13", default-features = false, features = [ "json", @@ -74,6 +77,7 @@ rust-s3 = { git = "https://github.com/w4nderlust/rust-s3", rev = "2936eb7", defa "tokio-rustls-tls", "fail-on-err", ] } +maud = "0.27.0" [target.'cfg(target_env = "musl")'.dependencies] mimalloc = "0.1.52" diff --git a/migrations/20260714181725_create_developer_login_info_table.up.sql b/migrations/20260714181725_create_developer_login_info_table.up.sql index eba23b8..f6a95ce 100644 --- a/migrations/20260714181725_create_developer_login_info_table.up.sql +++ b/migrations/20260714181725_create_developer_login_info_table.up.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS developer_login_info ( developer_id INTEGER NOT NULL PRIMARY KEY, email TEXT NOT NULL, - email_verified_at DATETIMETZ, + email_verified_at TIMESTAMPTZ, password TEXT NOT NULL, FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE diff --git a/src/config.rs b/src/config.rs index e528622..49b86e0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,7 +105,7 @@ pub async fn build_config() -> anyhow::Result { let env_var = dotenvy::var("EMAIL_BLOCKLIST_PATH").unwrap_or("./blocklist.conf".into()); let path = PathBuf::from_str(&env_var).ok(); - path.map(|path| Blocklist::load(path)) + path.map(Blocklist::load) } else { None }; @@ -141,7 +141,15 @@ pub async fn build_config() -> anyhow::Result { .read_timeout(Duration::from_secs(30)) .build()?, check_dns_http_client, - mailer: smtp_config.map(|config| Mailer::new(Arc::new(LettreBackend::new(&config)?))), + mailer: smtp_config + .and_then(|config| match LettreBackend::new(&config) { + Ok(backend) => Some(Arc::new(backend)), + Err(e) => { + tracing::warn!("failed to create SMTP backend: {e}"); + None + } + }) + .map(|backend| Mailer::new(backend)), email_blocklist: blocklist, s3_sender: OnceLock::new(), }) @@ -226,6 +234,10 @@ impl AppData { self.mailer.as_ref() } + pub fn email_blocklist(&self) -> Option<&Blocklist> { + self.email_blocklist.as_ref() + } + /// Client that validates passed host, denies all private resolved IP addresses. /// Useful for preventing Server Side Request Forgery. /// diff --git a/src/database/mod.rs b/src/database/mod.rs index b19a744..4314c60 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -2,6 +2,8 @@ pub mod repository; #[derive(thiserror::Error, Debug)] pub enum DatabaseError { + #[error("invalid data in column")] + InvalidData, #[error("Invalid input: {0}")] InvalidInput(String), #[error("Unknown database error")] diff --git a/src/database/repository/email_setup_requests.rs b/src/database/repository/email_setup_requests.rs new file mode 100644 index 0000000..34b09d3 --- /dev/null +++ b/src/database/repository/email_setup_requests.rs @@ -0,0 +1,125 @@ +use argon2::PasswordHash; +use chrono::Utc; +use password_hash::PasswordHashString; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{database::DatabaseError, email::blocklist::ApprovedEmailAddress}; + +pub struct EmailSetupRequestRow { + pub token: Uuid, + pub developer_id: i32, + pub email: String, + password: String, +} + +impl EmailSetupRequestRow { + pub fn password(&self) -> Result, password_hash::Error> { + PasswordHash::new(&self.password) + } +} + +#[tracing::instrument(skip_all)] +pub async fn find_for_developer( + developer_id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query!( + "SELECT developer_id, email, password, token + FROM email_setup_requests + WHERE developer_id = $1 + AND expires_at > NOW()", + developer_id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|result| { + result.map(|e| EmailSetupRequestRow { + developer_id: e.developer_id, + token: e.token, + email: e.email, + password: e.password, + }) + }) +} + +#[tracing::instrument(skip_all)] +pub async fn find_for_token( + token: Uuid, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query!( + "SELECT developer_id, email, password, token + FROM email_setup_requests + WHERE token = $1", + token + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|result| { + result.map(|e| EmailSetupRequestRow { + developer_id: e.developer_id, + token: e.token, + email: e.email, + password: e.password, + }) + }) +} + +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] +pub async fn create( + uuid: Uuid, + developer_id: i32, + email: &ApprovedEmailAddress, + password: PasswordHashString, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let expires_at = Utc::now() + chrono::Duration::minutes(30); + + sqlx::query!( + "INSERT INTO email_setup_requests + (developer_id, email, password, expires_at, token) + VALUES + ($1, $2, $3, $4, $5)", + developer_id, + email.email().to_string(), + password.as_str(), + expires_at, + uuid + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all)] +pub async fn delete(token: &Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { + sqlx::query!("DELETE FROM email_setup_requests WHERE token = $1", token) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] +pub async fn delete_for_developer( + developer_id: i32, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + sqlx::query!( + "DELETE FROM email_setup_requests WHERE developer_id = $1", + developer_id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 7253922..3a6a4f5 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -2,6 +2,7 @@ pub mod auth_tokens; pub mod dependencies; pub mod deprecations; pub mod developers; +pub mod email_setup_requests; pub mod github_login_attempts; pub mod github_web_logins; pub mod incompatibilities; diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index 3ff8e5d..6295ee7 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -1,13 +1,12 @@ use std::{ collections::HashSet, - fs::File, path::{Path, PathBuf}, sync::Arc, }; use parking_lot::RwLock; -use crate::email::EmailAddress; +use crate::email::{EmailAddress, EmailError}; const MAX_BLOCKLIST_SIZE: u64 = 10 * 1024 * 1024; // 10 MB @@ -23,14 +22,11 @@ pub enum BlocklistError { pub struct ApprovedEmailAddress(EmailAddress); impl ApprovedEmailAddress { - pub fn parse( - email: EmailAddress, - blocklist: Option, - ) -> Result { + pub fn parse(email: EmailAddress, blocklist: Option<&Blocklist>) -> Result { match blocklist { Some(blocklist) => { if blocklist.is_blocked(&email.domain) { - Err(BlocklistError::BlockedDomain) + Err(EmailError::BlocklistError(BlocklistError::BlockedDomain)) } else { Ok(Self(email)) } @@ -44,6 +40,7 @@ impl ApprovedEmailAddress { } } +#[derive(Clone)] pub struct Blocklist { entries: Arc>>, path: Arc, @@ -53,7 +50,10 @@ impl Blocklist { pub fn load(path: PathBuf) -> Self { let entries = Self::read_file(&path) .inspect_err(|e| { - tracing::error!("failed to parse blocklist {path}, using empty blocklist: {e}") + tracing::error!( + "failed to parse blocklist {}, using empty blocklist: {e}", + path.display() + ) }) .unwrap_or_default(); @@ -68,8 +68,11 @@ impl Blocklist { } pub fn refresh(&self) { - let entries = Self::read_file(&path).inspect_err(|e| { - tracing::warn!("failed to parse blocklist {path}, skipping blocklist refresh: {e}") + let entries = Self::read_file(&self.path).inspect_err(|e| { + tracing::warn!( + "failed to parse blocklist {}, skipping blocklist refresh: {e}", + &self.path.display() + ) }); if let Ok(entries) = entries { diff --git a/src/email/lettre.rs b/src/email/lettre.rs index 1810461..71a03f1 100644 --- a/src/email/lettre.rs +++ b/src/email/lettre.rs @@ -1,13 +1,13 @@ use lettre::{ - Address, AsyncSmtpTransport, Message, Tokio1Executor, + Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, address::AddressError, message::{Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::authentication::Credentials, }; use crate::email::{ - EmailAddress, SmtpConfig, - mailer::{BoxFuture, EmailBody, MailerBackend, MailerError, OutgoingEmail}, + EmailAddress, SmtpConfig, SmtpSecurity, + mailer::{BoxFuture, EmailBody, MailerBackend, MailerError, MailerResult, OutgoingEmail}, }; pub struct LettreBackend { @@ -17,23 +17,30 @@ pub struct LettreBackend { impl LettreBackend { pub fn new(config: &SmtpConfig) -> anyhow::Result { - let transport = AsyncSmtpTransport::::relay(&config.host)? - .port(config.port) - .credentials(Credentials::new( - config.username.clone(), - config.password.clone(), - )) - .build(); + let mut builder = match config.security { + SmtpSecurity::None => { + AsyncSmtpTransport::::builder_dangerous(&config.host) + } + SmtpSecurity::StartTls => { + AsyncSmtpTransport::::starttls_relay(&config.host)? + } + SmtpSecurity::Wrapper => AsyncSmtpTransport::::relay(&config.host)?, + } + .port(config.port); + + if let (Some(user), Some(pass)) = (&config.username, &config.password) { + builder = builder.credentials(Credentials::new(user.clone(), pass.clone())); + } Ok(Self { - transport, + transport: builder.build(), from: Mailbox::new(config.from_name.clone(), config.from_address.parse()?), }) } } impl MailerBackend for LettreBackend { - fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, ()> { + fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, MailerResult<()>> { Box::pin(async move { let address = Address::try_from(email.to.email().clone()) .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; @@ -60,7 +67,7 @@ impl MailerBackend for LettreBackend { .send(message) .await .inspect_err(|e| tracing::error!("{:?}", e)) - .map_err(|e| MailerError::Send(e.to_string()))?; + .map_err(|e| MailerError::SendError(e.to_string()))?; Ok(()) }) diff --git a/src/email/mailer.rs b/src/email/mailer.rs index e46c1cc..7a3c662 100644 --- a/src/email/mailer.rs +++ b/src/email/mailer.rs @@ -4,6 +4,7 @@ use crate::email::blocklist::ApprovedEmailAddress; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; +#[derive(Clone, Debug, thiserror::Error)] pub enum MailerError { #[error("invalid message: {0}")] InvalidMessage(String), @@ -13,12 +14,25 @@ pub enum MailerError { pub type MailerResult = Result; +#[derive(Debug)] pub enum EmailBody { Plaintext(String), Html(String), Multipart { text: String, html: String }, } +impl EmailBody { + pub fn html_with_autogenerated_text(html: impl Into) -> MailerResult { + let html = html.into(); + let text = html2text::from_read(html.as_bytes(), 80) + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + + Ok(EmailBody::Multipart { text, html }) + } +} + +#[derive(Debug)] pub struct OutgoingEmail { pub to: ApprovedEmailAddress, pub subject: String, @@ -29,6 +43,7 @@ pub trait MailerBackend: Send + Sync { fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, MailerResult<()>>; } +#[derive(Clone)] pub struct Mailer { backend: Arc, } @@ -38,7 +53,8 @@ impl Mailer { Mailer { backend } } - pub async fn send(&self, email: &OutgoingEmail) -> MailerReuslt<()> { - self.backend.send(email); + pub async fn send(&self, email: &OutgoingEmail) -> MailerResult<()> { + self.backend.send(email).await?; + Ok(()) } } diff --git a/src/email/mod.rs b/src/email/mod.rs index 53d0147..6b84368 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -1,13 +1,24 @@ use std::fmt::Display; use std::str::FromStr; +use anyhow::anyhow; use validator::ValidateEmail; +use crate::email::blocklist::BlocklistError; + pub mod blocklist; pub mod lettre; pub mod mailer; -#[derive(thiserror::Error)] +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + #[error("email address parse error: {0}")] + ParseError(#[from] EmailAddressParseError), + #[error("email is not allowed: {0}")] + BlocklistError(#[from] BlocklistError), +} + +#[derive(Debug, thiserror::Error)] pub enum EmailAddressParseError { #[error("invalid email address")] InvalidEmail, @@ -19,11 +30,33 @@ pub struct EmailAddress { domain: String, } +pub enum SmtpSecurity { + None, + StartTls, + Wrapper, +} + +impl std::str::FromStr for SmtpSecurity { + type Err = String; + + fn from_str(s: &str) -> Result { + let lowercase = s.to_lowercase(); + + match lowercase.as_str() { + "none" => Ok(SmtpSecurity::None), + "starttls" => Ok(SmtpSecurity::StartTls), + "wrapper" => Ok(SmtpSecurity::Wrapper), + other => Err(format!("invalid SMTP_TLS value: {other}")), + } + } +} + pub struct SmtpConfig { pub host: String, pub port: u16, - pub username: String, - pub password: String, + pub security: SmtpSecurity, + pub username: Option, + pub password: Option, pub from_address: String, pub from_name: Option, } @@ -38,13 +71,17 @@ impl SmtpConfig { } Ok(Some(SmtpConfig { - host: dotenvy::var("SMTP_HOST").ok()?, + host: dotenvy::var("SMTP_HOST")?, port: dotenvy::var("SMTP_PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(DEFAULT_SMTP_PORT), - username: dotenvy::var("SMTP_USERNAME")?, - password: dotenvy::var("SMTP_PASSWORD")?, + security: dotenvy::var("SMTP_SECURITY") + .unwrap_or_else(|_| "starttls".into()) + .parse() + .map_err(|e| anyhow!("{}", e))?, + username: dotenvy::var("SMTP_USERNAME").ok(), + password: dotenvy::var("SMTP_PASSWORD").ok(), from_address: dotenvy::var("SMTP_FROM_ADDRESS")?, from_name: dotenvy::var("SMTP_FROM_NAME").ok(), })) diff --git a/src/endpoints/auth/database.rs b/src/endpoints/auth/database.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/endpoints/auth/mod.rs b/src/endpoints/auth/mod.rs index 286a08c..5d39c9f 100644 --- a/src/endpoints/auth/mod.rs +++ b/src/endpoints/auth/mod.rs @@ -9,6 +9,7 @@ use sqlx::Acquire; use utoipa::ToSchema; use uuid::Uuid; +pub mod database; pub mod github; #[derive(Serialize, ToSchema)] diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 670fc8b..50d348f 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,10 +1,23 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use argon2::password_hash::{SaltString, rand_core::OsRng}; +use argon2::{Argon2, PasswordHasher}; +use maud::html; use serde::{Deserialize, Serialize}; +use sqlx::Connection; use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; +use validator::Validate; + +use std::str::FromStr; use super::ApiError; use crate::config::AppData; -use crate::database::repository::{auth_tokens, developers, mods, refresh_tokens}; +use crate::database::repository::{ + auth_tokens, developers, email_setup_requests, mods, refresh_tokens, +}; +use crate::email::EmailAddress; +use crate::email::blocklist::ApprovedEmailAddress; +use crate::email::mailer::{EmailBody, OutgoingEmail}; use crate::types::api::{ApiResponse, PaginatedData}; use crate::types::models::developer::SelfDeveloper; use crate::{ @@ -384,6 +397,133 @@ pub async fn get_me(auth: Auth, data: web::Data) -> Result, +} + +/// Start email configuration +#[utoipa::path( + post, + path = "/v1/me/email/setup", + request_body = StartEmailConfigurationPayload, + tag = "developers", + responses( + (status = 201, description = "Email setup request started", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 409, description = "E-mail already configured") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/me/email/setup")] +#[tracing::instrument(skip_all)] +pub async fn setup_email( + auth: Auth, + data: web::Data, + json: web::Json, +) -> Result { + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; + let front_url = data.front_url(); + + // We need to redirect the user to a frontend URL. + // If by chance we don't have a base URL, let's just lock the implementation + if front_url.is_empty() { + return Err(ApiError::NotImplemented); + } + + let developer = auth.developer()?; + + // We're trying to keep the DB connection occupied as little as possible. + // So do this chunk, send the email with no DB connection hogged, + // then get another connection to finish the insert + { + let mut conn = data.db().acquire().await?; + let force = json.force.unwrap_or(false); + let existing = email_setup_requests::find_for_developer(developer.id, &mut conn).await?; + + if existing.is_some() && !force { + return Err(ApiError::Conflict( + "user already has an active email setup request".into(), + )); + } + } + + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email).expect("email validated by json struct"), + data.email_blocklist(), + )?; + + let salt = SaltString::generate(&mut OsRng); + let password = json.password.clone(); + + let hash = tokio::task::spawn_blocking(move || { + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|_| ApiError::InternalError("failed to hash password".into())) + .map(|hash| hash.serialize()) + .map_err(|_| ApiError::InternalError("failed to hash password".into())) + }) + .await + .map_err(|e| ApiError::InternalError(format!("hashing task panicked: {e}")))??; + + let uuid = Uuid::new_v4(); + + let endpoint = format!("{}/email/verify?token={}", data.front_url(), uuid); + + let html = html! { + p { "Hi " (developer.display_name) "," } + p { + "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." + } + p { + "If you didn't request this, you can safely ignore this email. No changes will be made to your account." + } + p { + a href=(endpoint) { "Confirm this email address" } + } + p { + "Or paste this link into your browser:" br; + (endpoint) + } + p { + "This link expires in 30 minutes." + } + } + .into_string(); + + let outgoing = OutgoingEmail { + to: email.clone(), + subject: "Email setup | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(html)?, + }; + + mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + email_setup_requests::delete_for_developer(developer.id, &mut tx).await?; + email_setup_requests::create(uuid, developer.id, &email, hash, &mut tx).await?; + tx.commit().await?; + + Ok(HttpResponse::Created().json(ApiResponse { + payload: "".to_string(), + error: "".into(), + })) +} + #[derive(Deserialize, IntoParams)] struct GetDeveloperPath { id: i32, diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index f020bc0..eddfa8f 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,4 +1,5 @@ use crate::{ + email::mailer::MailerError, mod_zip::ModZipError, types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; @@ -22,11 +23,15 @@ pub enum ApiError { Authentication(#[from] crate::auth::AuthenticationError), #[error("You do not have access to this resource")] Authorization, + #[error("Conflict: {0}")] + Conflict(String), #[error("{0}")] Database(#[from] crate::database::DatabaseError), #[error("{0}")] Storage(#[from] crate::storage::StorageError), #[error("{0}")] + Email(#[from] crate::email::EmailError), + #[error("{0}")] ModZip(#[from] ModZipError), #[error("Database error")] SqlxError(#[from] sqlx::Error), @@ -41,6 +46,8 @@ pub enum ApiError { InternalError(String), #[error("{0}")] NotFound(String), + #[error("this functionality is not available on this index instance")] + NotImplemented, #[error("Error: {0}")] PlatformParseError(#[from] PlatformParseError), #[error("Unable to unzip archive")] @@ -49,6 +56,8 @@ pub enum ApiError { Reqwest(#[from] reqwest::Error), #[error("I/O error: {0}")] IO(#[from] std::io::Error), + #[error("failed to send email")] + Mailer(#[from] MailerError), } impl ApiError { diff --git a/src/main.rs b/src/main.rs index d18e816..f3f7d30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,6 +132,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::developers::get_own_mods) .service(endpoints::developers::get_me) .service(endpoints::developers::update_developer) + .service(endpoints::developers::setup_email) .service(endpoints::tags::index) .service(endpoints::tags::detailed_index) .service(endpoints::stats::get_stats) diff --git a/src/openapi.rs b/src/openapi.rs index 4691b49..714701b 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -40,6 +40,7 @@ use crate::{endpoints, types}; endpoints::developers::get_own_mods, endpoints::developers::get_me, endpoints::developers::update_developer, + endpoints::developers::setup_email, endpoints::tags::index, endpoints::tags::detailed_index, endpoints::stats::get_stats, From fff435d9542fd7c8a84c008394c3b562532981f4 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 7 Sep 2026 22:40:41 +0300 Subject: [PATCH 14/43] feat: add /v1/email/setup/verify --- src/database/repository/developers.rs | 61 +++++++++++ .../repository/email_setup_requests.rs | 9 +- src/endpoints/developers.rs | 100 ++++++++++++++++++ src/main.rs | 1 + src/openapi.rs | 1 + 5 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 70baaf8..580ed71 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -1,6 +1,9 @@ use crate::database::DatabaseError; +use crate::email::blocklist::ApprovedEmailAddress; use crate::types::api::PaginatedData; use crate::types::models::developer::{Developer, ModDeveloper}; +use chrono::Utc; +use password_hash::PasswordHashString; use sqlx::PgConnection; use std::collections::HashMap; use uuid::Uuid; @@ -493,3 +496,61 @@ pub async fn has_accepted_mod(id: i32, conn: &mut PgConnection) -> Result Result, DatabaseError> { + sqlx::query_as!( + Developer, + "SELECT + d.id, + d.username, + d.display_name, + d.verified, + d.admin, + d.github_user_id as github_id + FROM developers d + INNER JOIN developer_login_info login ON login.developer_id = d.id + WHERE login.email = $1", + email + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +/// Sets developer_login_info for the specified developer_id, overwriting on developer_id conflict +/// Assumes that email is not associated to another developer, and doesn't handle a conflict on that unique index +#[tracing::instrument(skip_all, fields(developer_id = %id))] +pub async fn finalize_email_setup( + id: i32, + email: &ApprovedEmailAddress, + password: &PasswordHashString, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let verified_at = Utc::now(); + let email_str = email.email().to_string().to_lowercase(); + + sqlx::query!( + "INSERT INTO developer_login_info + (developer_id, email, email_verified_at, password) + VALUES ($1, $2, $3, $4) + ON CONFLICT (developer_id) + DO UPDATE + SET email = EXCLUDED.email, + email_verified_at = EXCLUDED.email_verified_at, + password = EXCLUDED.password", + id, + &email_str, + verified_at, + password.as_str() + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/email_setup_requests.rs b/src/database/repository/email_setup_requests.rs index 34b09d3..022ec03 100644 --- a/src/database/repository/email_setup_requests.rs +++ b/src/database/repository/email_setup_requests.rs @@ -1,4 +1,3 @@ -use argon2::PasswordHash; use chrono::Utc; use password_hash::PasswordHashString; use sqlx::PgConnection; @@ -14,8 +13,8 @@ pub struct EmailSetupRequestRow { } impl EmailSetupRequestRow { - pub fn password(&self) -> Result, password_hash::Error> { - PasswordHash::new(&self.password) + pub fn password(&self) -> Result { + PasswordHashString::new(&self.password) } } @@ -45,6 +44,7 @@ pub async fn find_for_developer( }) } +/// Finds a valid (not expired) EmailSetupRequest for the given token #[tracing::instrument(skip_all)] pub async fn find_for_token( token: Uuid, @@ -53,7 +53,8 @@ pub async fn find_for_token( sqlx::query!( "SELECT developer_id, email, password, token FROM email_setup_requests - WHERE token = $1", + WHERE token = $1 + AND expires_at > NOW()", token ) .fetch_optional(&mut *conn) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 50d348f..6086aba 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -2,6 +2,7 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use argon2::password_hash::{SaltString, rand_core::OsRng}; use argon2::{Argon2, PasswordHasher}; use maud::html; +use password_hash::PasswordHashString; use serde::{Deserialize, Serialize}; use sqlx::Connection; use utoipa::{IntoParams, ToSchema}; @@ -406,6 +407,7 @@ struct StartEmailConfigurationPayload { contains(pattern = "[0-9]", message = "password must contain at least 1 digit") )] password: String, + /// Force remove the existing request if not expired force: Option, } @@ -447,6 +449,16 @@ pub async fn setup_email( // then get another connection to finish the insert { let mut conn = data.db().acquire().await?; + + // Prevent someone stealing an address + let existing = developers::find_by_email(&json.email, &mut conn).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "Developer already exists with this email address".into(), + )); + } + let force = json.force.unwrap_or(false); let existing = email_setup_requests::find_for_developer(developer.id, &mut conn).await?; @@ -524,6 +536,94 @@ pub async fn setup_email( })) } +#[derive(Deserialize, ToSchema, Validate)] +struct VerifyEmailConfigurationPayload { + request_id: String, +} + +/// Finalize email configuration +#[utoipa::path( + post, + path = "/v1/me/email/setup/verify", + request_body = VerifyEmailConfigurationPayload, + tag = "developers", + responses( + (status = 200, description = "Email settings applied", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Setup request not found"), + (status = 409, description = "E-mail already configured") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/me/email/setup/verify")] +#[tracing::instrument(skip_all)] +pub async fn verify_email_setup( + data: web::Data, + json: web::Json, + auth: Auth, +) -> Result { + let developer = auth.developer()?; + + let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { + ApiError::NotFound( + "The request associated with this ID either does not exist or has expired".into(), + ) + })?; + + let mut conn = data.db().acquire().await?; + + let mut tx = conn.begin().await?; + + let request = email_setup_requests::find_for_token(request_id, &mut tx) + .await? + .ok_or(ApiError::NotFound( + "The request associated with this ID either does not exist or has expired".into(), + ))?; + + if request.developer_id != developer.id { + // Let's be a little sneaky here... >:) + return Err(ApiError::NotFound( + "The request associated with this ID either does not exist or has expired".into(), + )); + } + + let existing = developers::find_by_email(&request.email, &mut tx).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "Developer already exists with this email address".into(), + )); + } + + // Who knows, maybe the blocklist gets updated while the request is running + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&request.email) + .inspect_err(|e| tracing::error!("failed to parse email from EmailSetupRequest: {e}")) + .map_err(|e| { + ApiError::InternalError(format!( + "Failed to read email from email setup request: {e}" + )) + })?, + data.email_blocklist(), + )?; + + // This should basically never fail + let password = request.password() + .inspect_err(|e| tracing::error!("Invalid hashed password in EmailSetupRequest: {e}")) + .map_err(|_| ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again".into()))?; + + developers::finalize_email_setup(developer.id, &email, &password, &mut tx).await?; + + tx.commit().await?; + + Ok(HttpResponse::Ok().json(ApiResponse { + payload: "".to_string(), + error: "".into(), + })) +} + #[derive(Deserialize, IntoParams)] struct GetDeveloperPath { id: i32, diff --git a/src/main.rs b/src/main.rs index f3f7d30..71ef9dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::developers::get_me) .service(endpoints::developers::update_developer) .service(endpoints::developers::setup_email) + .service(endpoints::developers::verify_email_setup) .service(endpoints::tags::index) .service(endpoints::tags::detailed_index) .service(endpoints::stats::get_stats) diff --git a/src/openapi.rs b/src/openapi.rs index 714701b..b46bb8a 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -41,6 +41,7 @@ use crate::{endpoints, types}; endpoints::developers::get_me, endpoints::developers::update_developer, endpoints::developers::setup_email, + endpoints::developers::verify_email_setup, endpoints::tags::index, endpoints::tags::detailed_index, endpoints::stats::get_stats, From 5c246bac0d38f31b321bc56203c0c2eb34f4c675 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 9 Sep 2026 00:45:59 +0300 Subject: [PATCH 15/43] fix: make blocklist log warn for no path --- src/email/blocklist.rs | 2 +- src/endpoints/developers.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index 6295ee7..47094f6 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -50,7 +50,7 @@ impl Blocklist { pub fn load(path: PathBuf) -> Self { let entries = Self::read_file(&path) .inspect_err(|e| { - tracing::error!( + tracing::warn!( "failed to parse blocklist {}, using empty blocklist: {e}", path.display() ) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 6086aba..c19978d 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -2,7 +2,6 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use argon2::password_hash::{SaltString, rand_core::OsRng}; use argon2::{Argon2, PasswordHasher}; use maud::html; -use password_hash::PasswordHashString; use serde::{Deserialize, Serialize}; use sqlx::Connection; use utoipa::{IntoParams, ToSchema}; From 325c6ac343fad0f431c021a5909e7ef7b246c6a8 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 9 Sep 2026 01:41:13 +0300 Subject: [PATCH 16/43] feat: add /v1/login Also add peppering for passwords on top of salting, optional when self-hosting, but highly recommended. --- src/auth/mod.rs | 1 + src/auth/password.rs | 14 +++++ src/config.rs | 14 +++++ src/database/repository/developers.rs | 24 +++++++- src/endpoints/auth/database.rs | 81 +++++++++++++++++++++++++++ src/endpoints/developers.rs | 10 +++- src/main.rs | 1 + src/openapi.rs | 1 + src/types/models/developer.rs | 6 ++ 9 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/auth/password.rs diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 9d56c68..13f0d52 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,5 @@ pub mod github; +pub mod password; #[derive(thiserror::Error, Debug)] pub enum AuthenticationError { diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..1737017 --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,14 @@ +use argon2::{Algorithm, Argon2, Params, Version}; + +pub fn argon2(pepper: Option<&String>) -> Argon2<'_> { + match pepper { + Some(pepper) => Argon2::new_with_secret( + pepper.as_bytes(), + Algorithm::default(), + Version::default(), + Params::default(), + ) + .unwrap(), // This only fails if the secret is too long, it'll be fine! + None => Argon2::default(), + } +} diff --git a/src/config.rs b/src/config.rs index 49b86e0..3af67f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,7 @@ pub struct AppData { max_download_mb: u32, port: u16, debug: bool, + password_hash_pepper: Option, mods_cache: Cache>>, http_client: reqwest::Client, @@ -70,6 +71,14 @@ pub async fn build_config() -> anyhow::Result { let github_client = dotenvy::var("GITHUB_CLIENT_ID").unwrap_or("".to_string()); let github_secret = dotenvy::var("GITHUB_CLIENT_SECRET").unwrap_or("".to_string()); let webhook_url = dotenvy::var("DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); + let password_hash_pepper = dotenvy::var("PASSWORD_HASH_PEPPER").ok(); + + if password_hash_pepper.is_none() { + tracing::warn!( + "IMPORTANT: no password hash pepper found, password storage will be LESS secure. Check https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#peppering for more information." + ); + } + let index_admin_webhook_url = dotenvy::var("INDEX_ADMIN_DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); let disable_downloads = @@ -134,6 +143,7 @@ pub async fn build_config() -> anyhow::Result { max_download_mb, port, debug, + password_hash_pepper, mods_cache, http_client: reqwest::Client::builder() .pool_max_idle_per_host(4) @@ -206,6 +216,10 @@ impl AppData { self.debug } + pub fn password_hash_pepper(&self) -> Option<&String> { + self.password_hash_pepper.as_ref() + } + pub fn static_storage(&self) -> &PublicDisk { &self.static_storage } diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 580ed71..d47ed0b 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -1,7 +1,7 @@ use crate::database::DatabaseError; use crate::email::blocklist::ApprovedEmailAddress; use crate::types::api::PaginatedData; -use crate::types::models::developer::{Developer, ModDeveloper}; +use crate::types::models::developer::{Developer, DeveloperEmailLogin, ModDeveloper}; use chrono::Utc; use password_hash::PasswordHashString; use sqlx::PgConnection; @@ -522,6 +522,28 @@ pub async fn find_by_email( .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(email = %email))] +pub async fn find_login_data_by_email( + email: &str, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + DeveloperEmailLogin, + "SELECT + d.id, + login.email, + login.password as password_hash + FROM developers d + INNER JOIN developer_login_info login ON d.id = login.developer_id + WHERE email ILIKE $1", + email + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + /// Sets developer_login_info for the specified developer_id, overwriting on developer_id conflict /// Assumes that email is not associated to another developer, and doesn't handle a conflict on that unique index #[tracing::instrument(skip_all, fields(developer_id = %id))] diff --git a/src/endpoints/auth/database.rs b/src/endpoints/auth/database.rs index e69de29..82c484a 100644 --- a/src/endpoints/auth/database.rs +++ b/src/endpoints/auth/database.rs @@ -0,0 +1,81 @@ +use actix_web::{Responder, post, web}; +use argon2::{PasswordHash, PasswordVerifier}; +use serde::Deserialize; +use sqlx::Connection; +use utoipa::ToSchema; +use validator::Validate; + +use crate::{ + auth, + config::AppData, + database::repository::{auth_tokens, developers, refresh_tokens}, + endpoints::{ApiError, auth::TokensResponse}, + types::api::ApiResponse, +}; + +#[derive(ToSchema, Validate, Deserialize)] +struct EmailLogin { + #[validate(email)] + email: String, + password: String, +} + +/// Login using email +#[utoipa::path( + post, + path = "/v1/login", + tag = "auth", + responses( + (status = 200, description = "Authenticated", body = inline(ApiResponse)), + (status = 401, description = "Invalid login"), + (status = 500, description = "Server error") + ) +)] +#[post("v1/login")] +#[tracing::instrument(skip_all)] +pub async fn login( + data: web::Data, + json: web::Json, +) -> Result { + let login_data = { + let mut pool = data.db().acquire().await?; + + developers::find_login_data_by_email(&json.email, &mut pool) + .await? + .ok_or(ApiError::BadRequest( + "No user found with these credentials".into(), + ))? + }; + + let password_hash = login_data.password_hash.clone(); + let pepper = data.password_hash_pepper().cloned(); + + tokio::task::spawn_blocking(move || { + let parsed_hash = PasswordHash::new(&password_hash) + .inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}")) + .map_err(|_| ApiError::InternalError("couldn't read user password".into()))?; + + auth::password::argon2(pepper.as_ref()) + .verify_password(json.password.as_bytes(), &parsed_hash) + .map_err(|_| ApiError::BadRequest("No user found with these credentials".into())) + }) + .await + .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) + .map_err(|_| ApiError::InternalError("hash check task panicked".into()))??; + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + let token = auth_tokens::generate_token(login_data.id, true, &mut tx).await?; + let refresh = refresh_tokens::generate_token(login_data.id, &mut tx).await?; + + tx.commit().await?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: TokensResponse { + access_token: token.to_string(), + refresh_token: refresh.to_string(), + }, + })) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index c19978d..40ef7ae 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,6 +1,6 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use argon2::PasswordHasher; use argon2::password_hash::{SaltString, rand_core::OsRng}; -use argon2::{Argon2, PasswordHasher}; use maud::html; use serde::{Deserialize, Serialize}; use sqlx::Connection; @@ -11,6 +11,7 @@ use validator::Validate; use std::str::FromStr; use super::ApiError; +use crate::auth; use crate::config::AppData; use crate::database::repository::{ auth_tokens, developers, email_setup_requests, mods, refresh_tokens, @@ -476,8 +477,10 @@ pub async fn setup_email( let salt = SaltString::generate(&mut OsRng); let password = json.password.clone(); + let pepper = data.password_hash_pepper().cloned(); + let hash = tokio::task::spawn_blocking(move || { - Argon2::default() + auth::password::argon2(pepper.as_ref()) .hash_password(password.as_bytes(), &salt) .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|_| ApiError::InternalError("failed to hash password".into())) @@ -485,7 +488,8 @@ pub async fn setup_email( .map_err(|_| ApiError::InternalError("failed to hash password".into())) }) .await - .map_err(|e| ApiError::InternalError(format!("hashing task panicked: {e}")))??; + .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) + .map_err(|_| ApiError::InternalError("hashing task panicked".into()))??; let uuid = Uuid::new_v4(); diff --git a/src/main.rs b/src/main.rs index 71ef9dd..3ded309 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,6 +122,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::auth::github::poll_github_login) .service(endpoints::auth::github::github_token_login) .service(endpoints::auth::github::start_github_login) + .service(endpoints::auth::database::login) .service(endpoints::developers::developer_index) .service(endpoints::developers::get_developer) .service(endpoints::developers::add_developer_to_mod) diff --git a/src/openapi.rs b/src/openapi.rs index b46bb8a..3560965 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -50,6 +50,7 @@ use crate::{endpoints, types}; endpoints::loader::get_many, endpoints::health::health, endpoints::auth::refresh_token, + endpoints::auth::database::login, endpoints::auth::github::start_github_login, endpoints::auth::github::start_github_web_login, endpoints::auth::github::github_web_callback, diff --git a/src/types/models/developer.rs b/src/types/models/developer.rs index 4190e4f..81404e3 100644 --- a/src/types/models/developer.rs +++ b/src/types/models/developer.rs @@ -19,6 +19,12 @@ pub struct Developer { pub github_id: i64, } +pub struct DeveloperEmailLogin { + pub id: i32, + pub email: String, + pub password_hash: String, +} + #[derive(sqlx::FromRow, Serialize, Clone, Debug, ToSchema)] pub struct SelfDeveloper { pub id: i32, From 697db6a12718373a892f781b6afccc316bbfcc28 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Mon, 21 Sep 2026 23:46:23 +0300 Subject: [PATCH 17/43] fix: fix one revert migration --- .../20260810163519_add_unique_to_developer_login_infos.down.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql index a0e9456..8ebb274 100644 --- a/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql +++ b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql @@ -1,3 +1,3 @@ -- Add down migration script here -DROP INDEX developer_login_info_email_lower_idx ON developer_login_info; +DROP INDEX developer_login_info_email_lower_idx; From 6dd7e1768ca9b13a8b3b72595f453bf96af89338 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 22 Sep 2026 01:31:17 +0300 Subject: [PATCH 18/43] fix: delete urisign --- src/main.rs | 2 - src/middleware/mod.rs | 1 - src/middleware/signed.rs | 88 ------------ src/urisign.rs | 292 --------------------------------------- 4 files changed, 383 deletions(-) delete mode 100644 src/middleware/mod.rs delete mode 100644 src/middleware/signed.rs delete mode 100644 src/urisign.rs diff --git a/src/main.rs b/src/main.rs index 2d38995..2bcbed2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,13 +20,11 @@ mod events; mod extractors; mod jobs; mod logging; -mod middleware; mod mod_zip; mod openapi; mod s3_worker; mod storage; mod types; -mod urisign; mod webhook; // Avoid musl's default allocator due to lackluster performance diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs deleted file mode 100644 index 41e9259..0000000 --- a/src/middleware/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod signed; diff --git a/src/middleware/signed.rs b/src/middleware/signed.rs deleted file mode 100644 index 85204b8..0000000 --- a/src/middleware/signed.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::future::{Ready, ready}; - -use actix_web::{ - Error, HttpResponse, - body::EitherBody, - dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, -}; -use futures::future::LocalBoxFuture; -use http::StatusCode; - -use crate::{types::api::ApiResponse, urisign}; - -pub struct SignedUrl { - signing_key: String, -} - -impl SignedUrl { - pub fn new(signing_key: String) -> Self { - Self { signing_key } - } -} - -impl Transform for SignedUrl -where - S: Service, Error = Error>, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse>; - type Error = Error; - type InitError = (); - type Transform = SignedUrlMiddleware; - type Future = Ready>; - - fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(SignedUrlMiddleware { - service, - signing_key: self.signing_key.clone(), - })) - } -} - -pub struct SignedUrlMiddleware { - service: S, - signing_key: String, -} - -impl Service for SignedUrlMiddleware -where - S: Service, Error = Error>, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse>; - type Error = Error; - type Future = LocalBoxFuture<'static, Result>; - - forward_ready!(service); - - fn call(&self, req: ServiceRequest) -> Self::Future { - if let Err(e) = urisign::check_signed_uri(req.uri(), &self.signing_key) { - let response = match e { - urisign::CheckUriError::Expired => { - HttpResponse::build(StatusCode::GONE).json(ApiResponse { - error: format!("{}", e), - payload: "", - }) - } - urisign::CheckUriError::Invalid => { - HttpResponse::build(StatusCode::NOT_FOUND).json(ApiResponse { - error: "Not found".into(), - payload: "", - }) - } - }; - - let service_response = req.into_response(response).map_into_right_body(); - return Box::pin(ready(Ok(service_response))); - } - - let fut = self.service.call(req); - Box::pin(async move { - let res = fut.await?; - - Ok(res.map_into_left_body()) - }) - } -} diff --git a/src/urisign.rs b/src/urisign.rs deleted file mode 100644 index 4f1f86d..0000000 --- a/src/urisign.rs +++ /dev/null @@ -1,292 +0,0 @@ -use chrono::Utc; -use http::{Uri, uri::PathAndQuery}; -use sha2::Digest; -use std::fmt::Write; - -#[derive(Debug, PartialEq, Eq, thiserror::Error)] -pub enum CheckUriError { - #[error("signature has expired")] - Expired, - #[error("signature is invalid")] - Invalid, -} - -/// Returns a signed URI creeated using the passed URI. -/// -/// The returned URI contains a new `s` query param, containing -/// the signature of the URI. -/// -/// If the passed URL contains `s` as a query param, it will be discarded and will -/// not be added to the final hash. -/// -/// You can optionally offer a UNIX timestamp expiration for -/// the signed URI, which will be added to the query params as `exp`. -/// -/// IMPORTANT: query params named `s` or `exp` WILL BE REMOVED from the URI. -/// -/// # Example -/// -/// ```rust -/// use http::Uri; -/// -/// let uri = Uri::try_from("/path?test=1").unwrap(); -/// let signed = urisign::sign_uri(uri, "Wmfd2893gb7", None); // signed without an expiration -/// -/// let uri = Uri::try_from("/path?test=1").unwrap(); -/// let signed_with_exp = urisign::sign_uri(uri, "Wmfd2893gb7", Some(1691084580)); // signed with expiration date -/// ``` -pub fn sign_uri(uri: Uri, salt: &str, exp: Option) -> Uri { - let mut parts = uri.into_parts(); - - let path_and_query = parts - .path_and_query - .unwrap_or(PathAndQuery::from_static("")); - - let query: String = path_and_query.query().unwrap_or_default().into(); - - let (_, mut query) = strip_param(&query, "exp"); - - if let Some(exp) = exp { - let prefix = if query.is_empty() { "" } else { "&" }; - // This shouldn't fail (hopefully!) - let _ = write!(&mut query, "{}exp={}", prefix, exp); - } - - let (_, query) = strip_param(&query, "s"); - - let signature = compute_signature(salt, path_and_query.path(), &query); - - let signature_prefix = if query.is_empty() { "" } else { "&" }; - let path_and_query = PathAndQuery::try_from(format!( - "{}?{}{}s={}", - path_and_query.path(), - query, - signature_prefix, - signature - )) - .expect("rebuilt query is always valid"); - - parts.path_and_query = Some(path_and_query); - - Uri::from_parts(parts).expect("rebuilt Uri should be valid") -} - -/// Check if an URI has a valid signarture. -/// -/// The check happens on the `s` query param. If the param is missing, or the -/// signature doesn't match, `Err(CheckUriError::Invalid)` is returned. If the -/// `exp` param is present and in the past, `Err(CheckUriError::Expired)` is -/// returned instead. -/// -/// Should be safe against timing attacks, the check uses `constant_time_eq`. -/// -/// # Example -/// -/// ```rust -/// use http::Uri; -/// -/// let uri = Uri::try_from("/path?test=1").unwrap(); -/// -/// let signed = urisign::sign_uri(uri, "Wmfd2893gb7", None); -/// -/// let valid = urisign::check_signed_uri(&signed, "Wmfd2893gb7"); -/// -/// assert!(valid.is_ok()); -/// ``` -pub fn check_signed_uri(uri: &Uri, salt: &str) -> Result<(), CheckUriError> { - let query: String = uri.query().unwrap_or_default().into(); - - let (signature, query) = strip_param(&query, "s"); - - if signature.is_none() { - return Err(CheckUriError::Invalid); - } - - let exp: Option> = - find_param(&query, "exp").map(|exp| exp.parse()); - - if let Some(exp) = exp { - let now = Utc::now().timestamp(); - match exp { - Ok(exp) => { - if exp < now { - return Err(CheckUriError::Expired); - } - } - Err(_) => { - return Err(CheckUriError::Invalid); - } - } - } - - let signature = signature.unwrap(); - let computed = compute_signature(salt, uri.path(), &query); - - if constant_time_eq::constant_time_eq(signature.as_bytes(), computed.as_bytes()) { - Ok(()) - } else { - Err(CheckUriError::Invalid) - } -} - -fn compute_signature(salt: &str, path: &str, query_without_s: &str) -> String { - let mut hash = sha2::Sha256::new(); - - hash.update(salt.as_bytes()); - hash.update(path.as_bytes()); - hash.update(b"?"); - hash.update(query_without_s.as_bytes()); - - hex::encode(hash.finalize()) -} - -fn find_param<'a>(query: &'a str, key: &str) -> Option<&'a str> { - for component in query.split("&") { - let maybe_stripped = component - .strip_prefix(key) - .and_then(|rest| rest.strip_prefix("=")); - - if let Some(stripped) = maybe_stripped { - return Some(stripped); - } - } - - None -} - -fn strip_param<'a>(query: &'a str, key: &str) -> (Option<&'a str>, String) { - let mut ret = String::with_capacity(query.len()); - - let mut found: Option<&str> = None; - - for component in query.split("&") { - let maybe_stripped = component - .strip_prefix(key) - .and_then(|rest| rest.strip_prefix("=")); - - if let Some(stripped) = maybe_stripped { - found = Some(stripped); - } else { - ret.push_str(component); - ret.push('&'); - } - } - - if !ret.is_empty() { - ret.truncate(ret.trim_end_matches("&").len()); - } - - (found, ret) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn signed_uri_is_valid() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let signed = sign_uri(uri, "salt", None); - - assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); - } - - #[test] - fn wrong_salt_is_rejected() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let signed = sign_uri(uri, "salt", None); - - assert_eq!( - check_signed_uri(&signed, "wrong-salt"), - Err(CheckUriError::Invalid) - ); - } - - #[test] - fn missing_signature_is_rejected() { - let uri: Uri = "/path?test=1".parse().unwrap(); - - assert_eq!(check_signed_uri(&uri, "salt"), Err(CheckUriError::Invalid)); - } - - #[test] - fn tampered_query_is_rejected() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let signed = sign_uri(uri, "salt", None); - let existing_sig = find_param(signed.query().unwrap(), "s").unwrap(); - - let tampered: Uri = format!("/path?test=2&s={}", existing_sig).parse().unwrap(); - - assert_eq!( - check_signed_uri(&tampered, "salt"), - Err(CheckUriError::Invalid) - ); - } - - #[test] - fn tampered_signature_is_rejected() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let signed = sign_uri(uri, "salt", None); - let existing_sig = find_param(signed.query().unwrap(), "s").unwrap(); - - let bogus_sig = "0".repeat(existing_sig.len()); - let tampered: Uri = format!("/path?test=1&s={}", bogus_sig).parse().unwrap(); - - assert_eq!( - check_signed_uri(&tampered, "salt"), - Err(CheckUriError::Invalid) - ); - } - - #[test] - fn signing_ignores_existing_s_param() { - let uri: Uri = "/path?test=1&s=bogus".parse().unwrap(); - let signed = sign_uri(uri, "salt", None); - - assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); - } - - #[test] - fn exp_param_changes_signature() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let future = Utc::now().timestamp() as u64 + 3600; - - let signed_without_exp = sign_uri(uri.clone(), "salt", None); - let signed_with_exp = sign_uri(uri, "salt", Some(future)); - - assert_ne!(signed_without_exp, signed_with_exp); - assert_eq!(check_signed_uri(&signed_with_exp, "salt"), Ok(())); - } - - #[test] - fn future_exp_is_valid() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let future = Utc::now().timestamp() as u64 + 3600; - - let signed = sign_uri(uri, "salt", Some(future)); - - assert_eq!(check_signed_uri(&signed, "salt"), Ok(())); - } - - #[test] - fn expired_uri_is_rejected() { - let uri: Uri = "/path?test=1".parse().unwrap(); - let past = Utc::now().timestamp() as u64 - 3600; - - let signed = sign_uri(uri, "salt", Some(past)); - - assert_eq!(check_signed_uri(&signed, "salt"), Err(CheckUriError::Expired)); - } - - #[test] - fn malformed_exp_is_rejected() { - // Build a URI whose signature is genuinely valid for its own - // (malformed) `exp`, so this isolates the parse-failure path - // from a plain signature mismatch. - let query = "test=1&exp=not-a-number"; - let signature = compute_signature("salt", "/path", query); - let uri: Uri = format!("/path?{}&s={}", query, signature).parse().unwrap(); - - assert_eq!(check_signed_uri(&uri, "salt"), Err(CheckUriError::Invalid)); - } -} From 761958edcc15a0a4015ffeefaa686da6d9b528e5 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 22 Sep 2026 01:31:43 +0300 Subject: [PATCH 19/43] chore: cleanup dependencies --- Cargo.lock | 55 ++++++++++--------- Cargo.toml | 8 +-- src/database/repository/developers.rs | 3 +- .../repository/email_setup_requests.rs | 11 +--- src/endpoints/developers.rs | 23 +++++--- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d849b0a..ef7490d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,13 +424,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", ] @@ -656,11 +656,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -953,9 +953,12 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "constant_time_eq" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a1ec0cfec728a79a5109075543131387f911cb4d07716436d7ae20533657a96" +checksum = "6187e670c346a04bcbf5def0393d57ec0e5ab56565cde6be4a7cbdb6cdc2f3f6" +dependencies = [ + "rustversion", +] [[package]] name = "convert_case" @@ -1655,26 +1658,24 @@ dependencies = [ "bytes", "chrono", "clap", - "constant_time_eq 0.5.0", + "constant_time_eq 0.6.0", "dotenvy", "futures", "hex", "html2text", - "http 0.2.12", "image", "lettre", "maud", "mimalloc", "moka", "parking_lot", - "password-hash", + "phc", "regex", "reqwest 0.13.5", "rust-s3", "semver", "serde", "serde_json", - "sha2 0.11.0", "sha256", "sqlx", "thiserror", @@ -2936,13 +2937,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -2973,6 +2973,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3349,15 +3361,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 8131c2e..5e68aa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,22 +7,21 @@ edition = "2024" serve-static = ["dep:actix-files"] [dependencies] +argon2 = "0.6.0" actix-cors = "0.7" actix-files = { version = "0.7", optional = true } actix-multipart = "0.8" actix-web = "4.10" ammonia = "4" anyhow = "1.0" -argon2 = "0.5.3" bytes = "1.11" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive"] } -constant_time_eq = "0.5.0" +constant_time_eq = "0.6.0" dotenvy = "0.15" futures = "0.3" hex = "0.4.3" html2text = "0.17.1" -http = "0.2" # 0.2 is required for actix-web compat in middleware lettre = { version = "0.11.23", default-features = false, features = [ "builder", "hostname", @@ -35,7 +34,6 @@ lettre = { version = "0.11.23", default-features = false, features = [ image = "0.25" moka = { version = "0.12.13", features = ["future"] } parking_lot = "0.12.5" -password-hash = { version = "0.5", features = ["getrandom"] } regex = "1.10" reqwest = { version = "0.13", features = [ "gzip", @@ -48,7 +46,6 @@ reqwest = { version = "0.13", features = [ semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -sha2 = "0.11.0" # I know we already use sha256, it pulls sha2 anyway, I added it for urisign sha256 = "1.5" sqlx = { version = "0.9", features = [ "postgres", @@ -79,6 +76,7 @@ rust-s3 = { git = "https://github.com/w4nderlust/rust-s3", rev = "2936eb7", defa "fail-on-err", ] } maud = "0.27.0" +phc = "0.6.1" [profile.release] opt-level = 's' diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index d47ed0b..448cc5e 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -3,7 +3,6 @@ use crate::email::blocklist::ApprovedEmailAddress; use crate::types::api::PaginatedData; use crate::types::models::developer::{Developer, DeveloperEmailLogin, ModDeveloper}; use chrono::Utc; -use password_hash::PasswordHashString; use sqlx::PgConnection; use std::collections::HashMap; use uuid::Uuid; @@ -550,7 +549,7 @@ pub async fn find_login_data_by_email( pub async fn finalize_email_setup( id: i32, email: &ApprovedEmailAddress, - password: &PasswordHashString, + password: &String, conn: &mut PgConnection, ) -> Result<(), DatabaseError> { let verified_at = Utc::now(); diff --git a/src/database/repository/email_setup_requests.rs b/src/database/repository/email_setup_requests.rs index 022ec03..db35733 100644 --- a/src/database/repository/email_setup_requests.rs +++ b/src/database/repository/email_setup_requests.rs @@ -1,5 +1,4 @@ use chrono::Utc; -use password_hash::PasswordHashString; use sqlx::PgConnection; use uuid::Uuid; @@ -9,13 +8,7 @@ pub struct EmailSetupRequestRow { pub token: Uuid, pub developer_id: i32, pub email: String, - password: String, -} - -impl EmailSetupRequestRow { - pub fn password(&self) -> Result { - PasswordHashString::new(&self.password) - } + pub password: String, } #[tracing::instrument(skip_all)] @@ -76,7 +69,7 @@ pub async fn create( uuid: Uuid, developer_id: i32, email: &ApprovedEmailAddress, - password: PasswordHashString, + password: String, conn: &mut PgConnection, ) -> Result<(), DatabaseError> { let expires_at = Utc::now() + chrono::Duration::minutes(30); diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 40ef7ae..0309d68 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,7 +1,7 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use argon2::PasswordHasher; -use argon2::password_hash::{SaltString, rand_core::OsRng}; use maud::html; +use phc::PasswordHash; use serde::{Deserialize, Serialize}; use sqlx::Connection; use utoipa::{IntoParams, ToSchema}; @@ -474,14 +474,12 @@ pub async fn setup_email( data.email_blocklist(), )?; - let salt = SaltString::generate(&mut OsRng); let password = json.password.clone(); - let pepper = data.password_hash_pepper().cloned(); let hash = tokio::task::spawn_blocking(move || { auth::password::argon2(pepper.as_ref()) - .hash_password(password.as_bytes(), &salt) + .hash_password(password.as_bytes()) .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|_| ApiError::InternalError("failed to hash password".into())) .map(|hash| hash.serialize()) @@ -530,7 +528,7 @@ pub async fn setup_email( let mut pool = data.db().acquire().await?; let mut tx = pool.begin().await?; email_setup_requests::delete_for_developer(developer.id, &mut tx).await?; - email_setup_requests::create(uuid, developer.id, &email, hash, &mut tx).await?; + email_setup_requests::create(uuid, developer.id, &email, hash.to_string(), &mut tx).await?; tx.commit().await?; Ok(HttpResponse::Created().json(ApiResponse { @@ -612,10 +610,17 @@ pub async fn verify_email_setup( data.email_blocklist(), )?; - // This should basically never fail - let password = request.password() - .inspect_err(|e| tracing::error!("Invalid hashed password in EmailSetupRequest: {e}")) - .map_err(|_| ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again".into()))?; + let password = request.password; + + // This should basically never fail, but just check we have a valid + // phc string in the buffer + if let Err(e) = PasswordHash::new(&password) { + tracing::error!("Invalid hashed password in EmailSetupRequest: {}", e); + email_setup_requests::delete(&request.token, &mut tx).await?; + tx.commit().await?; + + return Err(ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again".into())); + } developers::finalize_email_setup(developer.id, &email, &password, &mut tx).await?; From 29cb0da053f153ecc9540697e8a3198335d4c201 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 22 Sep 2026 02:16:09 +0300 Subject: [PATCH 20/43] fix: more cleanup!! --- Cargo.lock | 59 ++----------------------------------- Cargo.toml | 13 ++++---- src/database/mod.rs | 2 -- src/endpoints/developers.rs | 3 +- 4 files changed, 11 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef7490d..89bbd8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,12 +877,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -1465,12 +1459,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - [[package]] name = "fdeflate" version = "0.3.7" @@ -1661,7 +1649,6 @@ dependencies = [ "constant_time_eq 0.6.0", "dotenvy", "futures", - "hex", "html2text", "image", "lettre", @@ -1731,16 +1718,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "h2" version = "0.3.27" @@ -2189,18 +2166,13 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", "exr", - "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", "ravif", "rayon", - "rgb", - "tiff", "zune-core", "zune-jpeg", ] @@ -2405,7 +2377,9 @@ dependencies = [ "socket2", "tokio", "tokio-rustls", + "tracing", "url", + "webpki-roots", ] [[package]] @@ -3221,15 +3195,6 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -4462,20 +4427,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.55" @@ -5130,12 +5081,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "whoami" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index 5e68aa7..a3714c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,18 +20,22 @@ clap = { version = "4.5", features = ["derive"] } constant_time_eq = "0.6.0" dotenvy = "0.15" futures = "0.3" -hex = "0.4.3" html2text = "0.17.1" lettre = { version = "0.11.23", default-features = false, features = [ "builder", "hostname", "smtp-transport", "pool", - "tokio1-rustls", - "ring", + "tokio1-rustls-tls", "rustls-platform-verifier", + "tracing", +] } +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "webp", + "rayon", ] } -image = "0.25" moka = { version = "0.12.13", features = ["future"] } parking_lot = "0.12.5" regex = "1.10" @@ -79,7 +83,6 @@ maud = "0.27.0" phc = "0.6.1" [profile.release] -opt-level = 's' lto = true [target.'cfg(target_env = "musl")'.dependencies] diff --git a/src/database/mod.rs b/src/database/mod.rs index 4314c60..b19a744 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -2,8 +2,6 @@ pub mod repository; #[derive(thiserror::Error, Debug)] pub enum DatabaseError { - #[error("invalid data in column")] - InvalidData, #[error("Invalid input: {0}")] InvalidInput(String), #[error("Unknown database error")] diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 0309d68..3815e87 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -482,8 +482,7 @@ pub async fn setup_email( .hash_password(password.as_bytes()) .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|_| ApiError::InternalError("failed to hash password".into())) - .map(|hash| hash.serialize()) - .map_err(|_| ApiError::InternalError("failed to hash password".into())) + .map(|hash| hash.to_string()) }) .await .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) From c3587993b620b0b70500f9f6a13e44f0684fbcba Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 6 Oct 2026 21:28:51 +0300 Subject: [PATCH 21/43] fix: I may have commited conflict markers --- src/database/repository/developers.rs | 63 +++++++++++++++++++-------- src/endpoints/developers.rs | 1 + 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 0aa6d9c..e8079f2 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -4,7 +4,7 @@ use crate::types::api::PaginatedData; use crate::types::models::developer::{Developer, DeveloperBan, DeveloperEmailLogin, ModDeveloper}; use chrono::Utc; use sqlx::PgConnection; -use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::chrono::DateTime; use std::collections::HashMap; use uuid::Uuid; @@ -497,7 +497,6 @@ pub async fn has_accepted_mod(id: i32, conn: &mut PgConnection) -> Result>, conn: &mut PgConnection, ) -> Result { - sqlx::query_as!(DeveloperBan, + sqlx::query_as!( + DeveloperBan, "INSERT INTO bans (developer_id, reason, admin_id, revoked_at) VALUES ($1, $2, $3, $4) RETURNING id, developer_id, reason, admin_id, created_at, revoked_at", - dev_id, reason, admin_id, revoked_at + dev_id, + reason, + admin_id, + revoked_at ) .fetch_one(&mut *conn) .await @@ -541,9 +550,18 @@ pub async fn check_ban( dev_id: i32, conn: &mut PgConnection, ) -> Result, DatabaseError> { - sqlx::query_as!(DeveloperBan, - "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 AND revoked_at > NOW() or revoked_at IS NULL ORDER BY revoked_at DESC NULLS FIRST, id DESC LIMIT 1", dev_id ->>>>>>> main + sqlx::query_as!( + DeveloperBan, + "SELECT + developer_id, reason, admin_id, created_at, + id, revoked_at + FROM bans + WHERE developer_id=$1 + AND revoked_at > NOW() + OR revoked_at IS NULL + ORDER BY revoked_at DESC NULLS FIRST, id DESC + LIMIT 1", + dev_id ) .fetch_optional(&mut *conn) .await @@ -551,7 +569,6 @@ pub async fn check_ban( .map_err(|e| e.into()) } -<<<<<<< HEAD #[tracing::instrument(skip_all, fields(email = %email))] pub async fn find_login_data_by_email( email: &str, @@ -569,7 +586,11 @@ pub async fn find_login_data_by_email( email ) .fetch_optional(&mut *conn) -======= + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + pub async fn get_bans( dev_id: i32, conn: &mut PgConnection, @@ -578,7 +599,6 @@ pub async fn get_bans( "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 ORDER BY revoked_at DESC NULLS FIRST, id DESC", dev_id ) .fetch_all(&mut *conn) ->>>>>>> main .await .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) @@ -622,14 +642,19 @@ pub async fn update_ban( revoked_at: Option>, reason: Option<&str>, developer_id: i32, - conn: &mut PgConnection + conn: &mut PgConnection, ) -> Result { - sqlx::query_as!(DeveloperBan, + sqlx::query_as!( + DeveloperBan, "UPDATE bans SET revoked_at=$2, reason=$3, admin_id=$4 WHERE id=$1 RETURNING id, developer_id, reason, admin_id, created_at, revoked_at", - ban_id, revoked_at, reason, developer_id) + ban_id, + revoked_at, + reason, + developer_id + ) .fetch_one(&mut *conn) .await .inspect_err(|e| tracing::error!("{:?}", e)) @@ -638,12 +663,14 @@ pub async fn update_ban( pub async fn delete_ban(dev_id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { if let Some(current_ban) = check_ban(dev_id, conn).await? { - sqlx::query!("UPDATE bans SET revoked_at=NOW() WHERE id=$1", current_ban.id) - .execute(conn) - .await + sqlx::query!( + "UPDATE bans SET revoked_at=NOW() WHERE id=$1", + current_ban.id + ) + .execute(conn) + .await .inspect_err(|e| tracing::error!("{:?}", e))?; } Ok(()) } - diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 163e59b..0fa587e 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -509,6 +509,7 @@ pub async fn setup_email( let endpoint = format!("{}/email/verify?token={}", data.front_url(), uuid); + // TODO: maybe make a good looking email template sometime in the future let html = html! { p { "Hi " (developer.display_name) "," } p { From 2d355324edc3856216bb8aaf6d18a51cfb8724f5 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Tue, 6 Oct 2026 22:51:53 +0300 Subject: [PATCH 22/43] feat: add POST /v1/email/change --- ...2_add_email_change_requests_table.down.sql | 1 + ...652_add_email_change_requests_table.up.sql | 10 ++ src/database/repository/developers.rs | 22 +++ .../repository/email_change_requests.rs | 72 +++++++++ src/database/repository/mod.rs | 1 + src/email/mod.rs | 1 + src/email/partials.rs | 18 +++ src/endpoints/developers.rs | 25 ++-- src/endpoints/email_change_requests.rs | 138 ++++++++++++++++++ src/endpoints/mod.rs | 1 + 10 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 src/database/repository/email_change_requests.rs create mode 100644 src/email/partials.rs create mode 100644 src/endpoints/email_change_requests.rs diff --git a/migrations/20260810163652_add_email_change_requests_table.down.sql b/migrations/20260810163652_add_email_change_requests_table.down.sql index 5717232..0bd32c3 100644 --- a/migrations/20260810163652_add_email_change_requests_table.down.sql +++ b/migrations/20260810163652_add_email_change_requests_table.down.sql @@ -1,3 +1,4 @@ -- Add down migration script here +DROP TABLE email_change_history; DROP TABLE email_change_requests; diff --git a/migrations/20260810163652_add_email_change_requests_table.up.sql b/migrations/20260810163652_add_email_change_requests_table.up.sql index 30ee23c..5654b35 100644 --- a/migrations/20260810163652_add_email_change_requests_table.up.sql +++ b/migrations/20260810163652_add_email_change_requests_table.up.sql @@ -9,3 +9,13 @@ CREATE TABLE email_change_requests ( FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE ); + +CREATE TABLE email_change_history ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + developer_id INTEGER NOT NULL, + old_email TEXT NOT NULL, + new_email TEXT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index e8079f2..112dba2 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -569,6 +569,28 @@ pub async fn check_ban( .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(id = %id))] +pub async fn find_login_data_for_developer( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + DeveloperEmailLogin, + "SELECT + d.id, + login.email, + login.password as password_hash + FROM developers d + INNER JOIN developer_login_info login ON d.id = login.developer_id + WHERE d.id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + #[tracing::instrument(skip_all, fields(email = %email))] pub async fn find_login_data_by_email( email: &str, diff --git a/src/database/repository/email_change_requests.rs b/src/database/repository/email_change_requests.rs new file mode 100644 index 0000000..5c1817a --- /dev/null +++ b/src/database/repository/email_change_requests.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{database::DatabaseError, email::blocklist::ApprovedEmailAddress}; + +pub struct EmailChangeRequestRow { + pub developer_id: i32, + pub token: Uuid, + pub new_email: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +pub async fn get_one( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "SELECT + developer_id, token, new_email, + created_at, expires_at + FROM email_change_requests + WHERE developer_id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + +pub async fn create( + id: i32, + token: &Uuid, + new_email: &ApprovedEmailAddress, + expires_at: &DateTime, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + EmailChangeRequestRow, + "INSERT INTO email_change_requests + (developer_id, token, new_email, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING + developer_id, token, new_email, + created_at, expires_at", + id, + token, + new_email.email().to_string(), + expires_at + ) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + +pub async fn delete(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "DELETE FROM email_change_requests + WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index a34e9c3..135c6ff 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -2,6 +2,7 @@ pub mod auth_tokens; pub mod dependencies; pub mod deprecations; pub mod developers; +pub mod email_change_requests; pub mod email_setup_requests; pub mod geode_versions; pub mod github_login_attempts; diff --git a/src/email/mod.rs b/src/email/mod.rs index 6b84368..7c9314a 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -9,6 +9,7 @@ use crate::email::blocklist::BlocklistError; pub mod blocklist; pub mod lettre; pub mod mailer; +pub mod partials; #[derive(Debug, thiserror::Error)] pub enum EmailError { diff --git a/src/email/partials.rs b/src/email/partials.rs new file mode 100644 index 0000000..096b176 --- /dev/null +++ b/src/email/partials.rs @@ -0,0 +1,18 @@ +use maud::{Markup, html}; + +pub fn salute(name: &str) -> Markup { + html!( + p { + "Hi, " (name) "!" + } + ) +} + +pub fn footer() -> Markup { + html!( + p { + "Thanks," br; + "The Geode Team" + } + ) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 0fa587e..f4f2d40 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -5,7 +5,6 @@ use maud::html; use phc::PasswordHash; use serde::{Deserialize, Serialize}; use sqlx::Acquire; -use sqlx::Connection; use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; @@ -17,9 +16,9 @@ use crate::config::AppData; use crate::database::repository::{ auth_tokens, developers, email_setup_requests, mods, refresh_tokens, }; -use crate::email::EmailAddress; use crate::email::blocklist::ApprovedEmailAddress; use crate::email::mailer::{EmailBody, OutgoingEmail}; +use crate::email::{EmailAddress, partials}; use crate::types::api::{ApiResponse, PaginatedData}; use crate::types::models::developer::SelfDeveloper; use crate::{ @@ -467,6 +466,15 @@ pub async fn setup_email( { let mut conn = data.db().acquire().await?; + let existing_email_login = + developers::find_login_data_for_developer(developer.id, &mut conn).await?; + + if existing_email_login.is_some() { + return Err(ApiError::Conflict( + "You already have email login setup. Use /v1/me/email/change for changing your email or /v1/me/password/change for changing your password".into(), + )); + } + // Prevent someone stealing an address let existing = developers::find_by_email(&json.email, &mut conn).await?; @@ -507,11 +515,11 @@ pub async fn setup_email( let uuid = Uuid::new_v4(); - let endpoint = format!("{}/email/verify?token={}", data.front_url(), uuid); + let endpoint = format!("{}/email/setup/verify?token={}", data.front_url(), uuid); // TODO: maybe make a good looking email template sometime in the future let html = html! { - p { "Hi " (developer.display_name) "," } + (partials::salute(&developer.display_name)) p { "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." } @@ -519,15 +527,14 @@ pub async fn setup_email( "If you didn't request this, you can safely ignore this email. No changes will be made to your account." } p { - a href=(endpoint) { "Confirm this email address" } - } - p { - "Or paste this link into your browser:" br; - (endpoint) + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address" } p { "This link expires in 30 minutes." } + (partials::footer()) } .into_string(); diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs new file mode 100644 index 0000000..ea97893 --- /dev/null +++ b/src/endpoints/email_change_requests.rs @@ -0,0 +1,138 @@ +use std::{ops::Add, str::FromStr}; + +use actix_web::{HttpResponse, Responder, post, web}; +use chrono::{Duration, Utc}; +use maud::html; +use serde::Deserialize; +use sqlx::Connection; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + config::AppData, + database::repository, + email::{ + EmailAddress, + blocklist::ApprovedEmailAddress, + mailer::{EmailBody, OutgoingEmail}, + partials, + }, + endpoints::ApiError, + extractors::auth::Auth, + types::api::ApiResponse, +}; + +#[derive(Deserialize, Validate)] +struct StartEmailChangePayload { + #[validate(email, length(max = 512))] + email: String, + force: Option, +} + +#[post("v1/me/email/change")] +pub async fn start_email_change( + auth: Auth, + json: web::Json, + data: web::Data, +) -> Result { + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; + let front_url = data.front_url(); + + if front_url.is_empty() { + return Err(ApiError::NotImplemented); + } + + let developer = auth.developer()?; + let now = Utc::now(); + + { + let mut conn = data.db().acquire().await?; + + let existing_email_login = + repository::developers::find_login_data_for_developer(developer.id, &mut conn) + .await? + .ok_or_else(|| { + ApiError::Conflict( + "You don't have your email address setup. Use /v1/me/email/setup".into(), + ) + })?; + + let exists = repository::email_change_requests::get_one(developer.id, &mut conn).await?; + + let force = json.force.unwrap_or(false); + let exists_and_is_valid = exists + .as_ref() + .is_some_and(|exists| exists.expires_at.gt(&now)); + + if !force && exists_and_is_valid { + return Err(ApiError::Conflict( + "You already have a pending email change request. Please confirm you want to invalidate it.".into(), + )); + } + + if exists.is_some() { + repository::email_change_requests::delete(developer.id, &mut conn).await?; + } + + if existing_email_login.email.to_lowercase() == json.email.to_lowercase() { + return Err(ApiError::BadRequest( + "Your new email cannot be the same as your current email".into(), + )); + } + } + + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email).expect("email validated by json struct"), + data.email_blocklist(), + )?; + + let expiry_date = now.add(Duration::minutes(30)); + + let uuid = Uuid::new_v4(); + + let endpoint = format!("{}/email/change/verify?token={}", data.front_url(), uuid); + + // TODO: maybe make a good looking email template sometime in the future + let html = html! { + (partials::salute(&developer.display_name)) + p { + "Someone (hopefully you) requested to change the email address for your Geode SDK developer account to this one." + } + p { + "If you didn't request this, you can safely ignore this email." + } + p { + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address." + } + p { + "This link expires in 30 minutes." + } + (partials::footer()) + } + .into_string(); + + let outgoing = OutgoingEmail { + to: email.clone(), + subject: "Email change request | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(html)?, + }; + + mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + let mut conn = data.db().acquire().await?; + let mut tx = conn.begin().await?; + repository::email_change_requests::delete(developer.id, &mut tx).await?; + repository::email_change_requests::create(developer.id, &uuid, &email, &expiry_date, &mut tx) + .await?; + tx.commit().await?; + + Ok(HttpResponse::Created().json(ApiResponse { + payload: "".to_string(), + error: "".into(), + })) +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index dc14ff9..ae64d3b 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -9,6 +9,7 @@ use validator::{ValidationError, ValidationErrors}; pub mod auth; pub mod deprecations; pub mod developers; +pub mod email_change_requests; pub mod health; pub mod loader; pub mod mod_status_badge; From 1393cef9532d37ab244f794af916fddf215d8160 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:04:12 +0300 Subject: [PATCH 23/43] fix: be consistent with "email" --- src/endpoints/developers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index f4f2d40..a055bc5 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -436,7 +436,7 @@ struct StartEmailConfigurationPayload { responses( (status = 201, description = "Email setup request started", body = inline(ApiResponse)), (status = 401, description = "Unauthorized"), - (status = 409, description = "E-mail already configured") + (status = 409, description = "Email already configured") ), security( ("bearer_token" = []) @@ -576,7 +576,7 @@ struct VerifyEmailConfigurationPayload { (status = 200, description = "Email settings applied", body = inline(ApiResponse)), (status = 401, description = "Unauthorized"), (status = 404, description = "Setup request not found"), - (status = 409, description = "E-mail already configured") + (status = 409, description = "Email already configured") ), security( ("bearer_token" = []) From 6358a2f6c633437e8af044c1aa3aa0a5ca0d3864 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:42:41 +0300 Subject: [PATCH 24/43] fix: this has no reason to take &String --- src/database/repository/developers.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 112dba2..302fc5f 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -626,13 +626,11 @@ pub async fn get_bans( .map_err(|e| e.into()) } -/// Sets developer_login_info for the specified developer_id, overwriting on developer_id conflict -/// Assumes that email is not associated to another developer, and doesn't handle a conflict on that unique index #[tracing::instrument(skip_all, fields(developer_id = %id))] pub async fn finalize_email_setup( id: i32, email: &ApprovedEmailAddress, - password: &String, + password: &str, conn: &mut PgConnection, ) -> Result<(), DatabaseError> { let verified_at = Utc::now(); @@ -650,7 +648,7 @@ pub async fn finalize_email_setup( id, &email_str, verified_at, - password.as_str() + password ) .execute(&mut *conn) .await From fea796362d130c74c0355e96653409f34ec33a8c Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:43:55 +0300 Subject: [PATCH 25/43] feat: add doc & tracing span to email change setup --- src/endpoints/email_change_requests.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index ea97893..c0924b0 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -5,6 +5,7 @@ use chrono::{Duration, Utc}; use maud::html; use serde::Deserialize; use sqlx::Connection; +use utoipa::ToSchema; use uuid::Uuid; use validator::Validate; @@ -22,13 +23,29 @@ use crate::{ types::api::ApiResponse, }; -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, ToSchema)] struct StartEmailChangePayload { #[validate(email, length(max = 512))] email: String, force: Option, } +/// Finalize email configuration +#[utoipa::path( + request_body = StartEmailChangePayload, + tag = "developers", + responses( + (status = 201, description = "Request created", body = inline(ApiResponse)), + (status = 400, description = "New email is same as old email"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Setup request not found"), + (status = 409, description = "You already have a pending request, retry with \"force\" in JSON to confirm / Email address is not setup") + ), + security( + ("bearer_token" = []) + ) +)] +#[tracing::instrument(skip_all)] #[post("v1/me/email/change")] pub async fn start_email_change( auth: Auth, From c1bd0c864c2286f7f131e90a8d201b519f335653 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:44:23 +0300 Subject: [PATCH 26/43] fix: let's be consistent with naming --- src/database/repository/email_change_requests.rs | 2 +- src/endpoints/email_change_requests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/database/repository/email_change_requests.rs b/src/database/repository/email_change_requests.rs index 5c1817a..2d7235f 100644 --- a/src/database/repository/email_change_requests.rs +++ b/src/database/repository/email_change_requests.rs @@ -12,7 +12,7 @@ pub struct EmailChangeRequestRow { pub expires_at: DateTime, } -pub async fn get_one( +pub async fn find_one( id: i32, conn: &mut PgConnection, ) -> Result, DatabaseError> { diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index c0924b0..d318edd 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -74,7 +74,7 @@ pub async fn start_email_change( ) })?; - let exists = repository::email_change_requests::get_one(developer.id, &mut conn).await?; + let exists = repository::email_change_requests::find_one(developer.id, &mut conn).await?; let force = json.force.unwrap_or(false); let exists_and_is_valid = exists From 519aa447b754b95b9919fca92ae10fe2f7392006 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:45:31 +0300 Subject: [PATCH 27/43] fix: actually validate JSON body --- src/endpoints/developers.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index a055bc5..f261b20 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -449,6 +449,8 @@ pub async fn setup_email( data: web::Data, json: web::Json, ) -> Result { + json.validate()?; + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; let front_url = data.front_url(); @@ -589,6 +591,8 @@ pub async fn verify_email_setup( json: web::Json, auth: Auth, ) -> Result { + json.validate()?; + let developer = auth.developer()?; let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { @@ -598,7 +602,6 @@ pub async fn verify_email_setup( })?; let mut conn = data.db().acquire().await?; - let mut tx = conn.begin().await?; let request = email_setup_requests::find_for_token(request_id, &mut tx) @@ -650,10 +653,7 @@ pub async fn verify_email_setup( tx.commit().await?; - Ok(HttpResponse::Ok().json(ApiResponse { - payload: "".to_string(), - error: "".into(), - })) + Ok(HttpResponse::NoContent()) } #[derive(Deserialize, IntoParams)] From 43ad8b02112b4b698ea7a4de6d1cd39a9a1e469c Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:50:54 +0300 Subject: [PATCH 28/43] feat: add /v1/email/change/verify --- src/database/repository/developers.rs | 52 +++++++++++++ .../repository/email_change_requests.rs | 19 +++++ src/endpoints/email_change_requests.rs | 76 +++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 302fc5f..70e2f06 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -657,6 +657,58 @@ pub async fn finalize_email_setup( .map_err(|e| e.into()) } +pub async fn get_email(id: i32, conn: &mut PgConnection) -> Result, DatabaseError> { + sqlx::query!( + "SELECT email FROM developer_login_info + WHERE developer_id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|r| r.map(|r| r.email)) + .map_err(|e| e.into()) +} + +/// Assumes developer_login_info exists for given ID. +/// Also adds a history entry. +#[tracing::instrument(skip_all, fields(developer_id = %id))] +pub async fn change_email( + id: i32, + old_email: &str, + email: &ApprovedEmailAddress, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let now = Utc::now(); + let email_str = email.email().to_string().to_lowercase(); + + sqlx::query!( + "UPDATE developer_login_info + SET email = $1 + WHERE developer_id = $2", + email_str, + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + sqlx::query!( + "INSERT INTO email_change_history + (developer_id, old_email, new_email, changed_at) + VALUES ($1, $2, $3, $4)", + id, + old_email, + email_str, + now + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + Ok(()) +} + pub async fn update_ban( ban_id: i32, revoked_at: Option>, diff --git a/src/database/repository/email_change_requests.rs b/src/database/repository/email_change_requests.rs index 2d7235f..b6e5051 100644 --- a/src/database/repository/email_change_requests.rs +++ b/src/database/repository/email_change_requests.rs @@ -31,6 +31,25 @@ pub async fn find_one( .map_err(|e| e.into()) } +pub async fn find_one_by_token( + token: &Uuid, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "SELECT + developer_id, token, new_email, + created_at, expires_at + FROM email_change_requests + WHERE token = $1", + token + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + pub async fn create( id: i32, token: &Uuid, diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index d318edd..eb8a6b0 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -153,3 +153,79 @@ pub async fn start_email_change( error: "".into(), })) } + +#[derive(Deserialize, ToSchema)] +struct FinalizeEmailChange { + request_id: String, +} + +/// Finalize email change +#[utoipa::path( + request_body = FinalizeEmailChange, + tag = "developers", + responses( + (status = 200, description = "Email changed", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Change request not found"), + (status = 409, description = "You don't actually an email setup or you already have the requested address (should never happen)") + ), + security( + ("bearer_token" = []) + ) +)] +#[tracing::instrument(skip_all)] +#[post("v1/me/email/change/verify")] +pub async fn finalize_email_change( + data: web::Data, + json: web::Json, + auth: Auth, +) -> Result { + let developer = auth.developer()?; + + let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { + ApiError::NotFound( + "The request associated with this ID either does not exist or has expired".into(), + ) + })?; + + let mut conn = data.db().acquire().await?; + let mut tx = conn.begin().await?; + + let not_found_msg = "The request associated with this ID either does not exist or has expired"; + + let request = repository::email_change_requests::find_one_by_token(&request_id, &mut tx) + .await? + .ok_or(ApiError::NotFound(not_found_msg.into()))?; + + if request.developer_id != developer.id { + return Err(ApiError::NotFound(not_found_msg.into())); + } + + let existing = repository::developers::find_by_email(&request.new_email, &mut tx).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "You already have the requested email address! Congratulations!".into(), + )); + } + + let current_email = repository::developers::get_email(developer.id, &mut tx).await? + .ok_or(ApiError::Conflict("Somehow, you don't have an email address, but manged to get this far. Congratulations! You broke the index!".into()))?; + + // Who knows, maybe the blocklist gets updated while the request is running + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&request.new_email) + .inspect_err(|e| tracing::error!("failed to parse email from EmailChangeRequest: {e}")) + .map_err(|e| { + ApiError::InternalError(format!("Failed to read email from request: {e}")) + })?, + data.email_blocklist(), + )?; + + repository::developers::change_email(developer.id, ¤t_email, &email, &mut tx).await?; + repository::email_change_requests::delete(developer.id, &mut tx).await?; + + tx.commit().await?; + + Ok(HttpResponse::NoContent()) +} From d8e4f2bf90bb1870e14d31bb8df8aaee3be81cab Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 22:51:53 +0300 Subject: [PATCH 29/43] fix: prevent possible SQL tomfoolery --- src/database/repository/developers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 70e2f06..c81d33f 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -604,7 +604,7 @@ pub async fn find_login_data_by_email( login.password as password_hash FROM developers d INNER JOIN developer_login_info login ON d.id = login.developer_id - WHERE email ILIKE $1", + WHERE LOWER(email) = LOWER($1)", email ) .fetch_optional(&mut *conn) From 29730a51e8d353fbe97eaf03bcca8662c9e316f7 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 23:22:09 +0300 Subject: [PATCH 30/43] fix: ensure lowercase emails --- src/database/repository/developers.rs | 2 +- src/endpoints/developers.rs | 4 ++-- src/endpoints/email_change_requests.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index c81d33f..f3a5c75 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -513,7 +513,7 @@ pub async fn find_by_email( d.github_user_id as github_id FROM developers d INNER JOIN developer_login_info login ON login.developer_id = d.id - WHERE login.email = $1", + WHERE LOWER(login.email) = LOWER($1)", email ) .fetch_optional(&mut *conn) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index f261b20..3f14c6e 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -497,7 +497,7 @@ pub async fn setup_email( } let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&json.email).expect("email validated by json struct"), + EmailAddress::from_str(&json.email.to_lowercase()).expect("email validated by json struct"), data.email_blocklist(), )?; @@ -627,7 +627,7 @@ pub async fn verify_email_setup( // Who knows, maybe the blocklist gets updated while the request is running let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&request.email) + EmailAddress::from_str(&request.email.to_lowercase()) .inspect_err(|e| tracing::error!("failed to parse email from EmailSetupRequest: {e}")) .map_err(|e| { ApiError::InternalError(format!( diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index eb8a6b0..f647ed0 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -99,7 +99,7 @@ pub async fn start_email_change( } let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&json.email).expect("email validated by json struct"), + EmailAddress::from_str(&json.email.to_lowercase()).expect("email validated by json struct"), data.email_blocklist(), )?; @@ -214,7 +214,7 @@ pub async fn finalize_email_change( // Who knows, maybe the blocklist gets updated while the request is running let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&request.new_email) + EmailAddress::from_str(&request.new_email.to_lowercase()) .inspect_err(|e| tracing::error!("failed to parse email from EmailChangeRequest: {e}")) .map_err(|e| { ApiError::InternalError(format!("Failed to read email from request: {e}")) From 3b775768e18e331ccfd005c79c18c4dc0d0c3aea Mon Sep 17 00:00:00 2001 From: Fleeym Date: Wed, 7 Oct 2026 23:22:22 +0300 Subject: [PATCH 31/43] fix: actually check change request expiration --- src/endpoints/email_change_requests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index f647ed0..b4a21d8 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -188,6 +188,8 @@ pub async fn finalize_email_change( ) })?; + let now = Utc::now(); + let mut conn = data.db().acquire().await?; let mut tx = conn.begin().await?; @@ -201,6 +203,10 @@ pub async fn finalize_email_change( return Err(ApiError::NotFound(not_found_msg.into())); } + if request.expires_at.lt(&now) { + return Err(ApiError::NotFound(not_found_msg.into())); + } + let existing = repository::developers::find_by_email(&request.new_email, &mut tx).await?; if existing.is_some_and(|e| e.id != developer.id) { From 4bb15f30983be5d53000042302d9f02f97e92942 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:41:29 +0300 Subject: [PATCH 32/43] feat: warn if not using SMTP security --- src/config.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 3b356be..df8636c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,9 @@ use tokio::sync::mpsc::Sender; use crate::{ auth::github::GithubClient, dns::ValidateDnsResolver, - email::{SmtpConfig, blocklist::Blocklist, lettre::LettreBackend, mailer::Mailer}, + email::{ + SmtpConfig, SmtpSecurity, blocklist::Blocklist, lettre::LettreBackend, mailer::Mailer, + }, endpoints::mods::IndexQueryParams, s3_worker::S3WorkerTask, storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration}, @@ -105,6 +107,16 @@ pub async fn build_config() -> anyhow::Result { let smtp_config = SmtpConfig::from_env()?; + if !debug + && smtp_config + .as_ref() + .is_some_and(|x| x.security == SmtpSecurity::None) + { + tracing::warn!( + "You are using no SMTP security for sending emails with APP_DEBUG=0. You have been warned." + ); + } + let blocklist = if smtp_config.is_some() { let env_var = dotenvy::var("EMAIL_BLOCKLIST_PATH").unwrap_or("./blocklist.conf".into()); let path = PathBuf::from_str(&env_var).ok(); From 6f8449738f3857c46b9cd3fa6ce9253fe2d976c5 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:43:19 +0300 Subject: [PATCH 33/43] fix: cleanup setup / change requests properly --- src/database/repository/developers.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index f3a5c75..85e1461 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -652,9 +652,17 @@ pub async fn finalize_email_setup( ) .execute(&mut *conn) .await - .inspect_err(|e| tracing::error!("{:?}", e)) - .map(|_| ()) - .map_err(|e| e.into()) + .inspect_err(|e| tracing::error!("{:?}", e))?; + + sqlx::query!( + "DELETE FROM email_setup_requests WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + Ok(()) } pub async fn get_email(id: i32, conn: &mut PgConnection) -> Result, DatabaseError> { @@ -693,6 +701,14 @@ pub async fn change_email( .await .inspect_err(|e| tracing::error!("{:?}", e))?; + sqlx::query!( + "DELETE FROM email_change_requests WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + sqlx::query!( "INSERT INTO email_change_history (developer_id, old_email, new_email, changed_at) From 8484681893354844ceb2023a052a686d8d3f360b Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:43:35 +0300 Subject: [PATCH 34/43] fix: set status codes for some more errors --- src/endpoints/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index ae64d3b..abc01cb 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -82,6 +82,9 @@ impl actix_web::ResponseError for ApiError { ApiError::TooManyRequests(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::NotFound(_) => StatusCode::NOT_FOUND, ApiError::BadRequest(..) => StatusCode::BAD_REQUEST, + ApiError::Conflict(..) => StatusCode::CONFLICT, + ApiError::NotImplemented => StatusCode::NOT_IMPLEMENTED, + ApiError::Email(..) => StatusCode::BAD_REQUEST, ApiError::ModZip(err) => err.status_code(), ApiError::PlatformParseError(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, From a1d75a653149ac970bba59aecb23ee6dd84958d5 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:46:55 +0300 Subject: [PATCH 35/43] fix: more misc fixes - remove display name from salutes - always parse input email as EmailAddress - reduce timing attack possibility on login --- src/email/partials.rs | 4 +-- src/endpoints/auth/database.rs | 37 +++++++++++++++++++++----- src/endpoints/developers.rs | 17 ++++++------ src/endpoints/email_change_requests.rs | 24 +++++++++-------- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/email/partials.rs b/src/email/partials.rs index 096b176..57ce29b 100644 --- a/src/email/partials.rs +++ b/src/email/partials.rs @@ -1,9 +1,9 @@ use maud::{Markup, html}; -pub fn salute(name: &str) -> Markup { +pub fn salute() -> Markup { html!( p { - "Hi, " (name) "!" + "Hi!" } ) } diff --git a/src/endpoints/auth/database.rs b/src/endpoints/auth/database.rs index 82c484a..b591632 100644 --- a/src/endpoints/auth/database.rs +++ b/src/endpoints/auth/database.rs @@ -1,5 +1,7 @@ +use std::{str::FromStr, sync::OnceLock}; + use actix_web::{Responder, post, web}; -use argon2::{PasswordHash, PasswordVerifier}; +use argon2::{PasswordHash, PasswordHasher, PasswordVerifier}; use serde::Deserialize; use sqlx::Connection; use utoipa::ToSchema; @@ -9,6 +11,7 @@ use crate::{ auth, config::AppData, database::repository::{auth_tokens, developers, refresh_tokens}, + email::EmailAddress, endpoints::{ApiError, auth::TokensResponse}, types::api::ApiResponse, }; @@ -40,17 +43,22 @@ pub async fn login( let login_data = { let mut pool = data.db().acquire().await?; - developers::find_login_data_by_email(&json.email, &mut pool) - .await? - .ok_or(ApiError::BadRequest( - "No user found with these credentials".into(), - ))? + let email = EmailAddress::from_str(&json.email) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))? + .to_string(); + + developers::find_login_data_by_email(&email, &mut pool).await? }; - let password_hash = login_data.password_hash.clone(); + let password_hash = login_data.as_ref().map(|data| &data.password_hash).cloned(); let pepper = data.password_hash_pepper().cloned(); tokio::task::spawn_blocking(move || { + // Run the hash check anyway to reduce timing attack probability. + // Of course, someone putting in "soggymod" would pass this (if no dev exists with given email) + // but will fail when login data is unwrapped. + let password_hash = password_hash.unwrap_or_else(|| dummy_hash(pepper.as_ref()).to_owned()); + let parsed_hash = PasswordHash::new(&password_hash) .inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}")) .map_err(|_| ApiError::InternalError("couldn't read user password".into()))?; @@ -63,6 +71,10 @@ pub async fn login( .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) .map_err(|_| ApiError::InternalError("hash check task panicked".into()))??; + let login_data = login_data.ok_or(ApiError::BadRequest( + "No user found with these credentials".into(), + ))?; + let mut pool = data.db().acquire().await?; let mut tx = pool.begin().await?; @@ -79,3 +91,14 @@ pub async fn login( }, })) } + +fn dummy_hash(pepper: Option<&String>) -> &'static str { + static HASH: OnceLock = OnceLock::new(); + + HASH.get_or_init(|| { + auth::password::argon2(pepper) + .hash_password(b"soggymod") + .expect("hashing a static str can't fail") + .to_string() + }) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 3f14c6e..9764dbd 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -462,6 +462,12 @@ pub async fn setup_email( let developer = auth.developer()?; + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email.to_lowercase()) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))?, + data.email_blocklist(), + )?; + // We're trying to keep the DB connection occupied as little as possible. // So do this chunk, send the email with no DB connection hogged, // then get another connection to finish the insert @@ -478,7 +484,7 @@ pub async fn setup_email( } // Prevent someone stealing an address - let existing = developers::find_by_email(&json.email, &mut conn).await?; + let existing = developers::find_by_email(&email.email().to_string(), &mut conn).await?; if existing.is_some_and(|e| e.id != developer.id) { return Err(ApiError::Conflict( @@ -496,11 +502,6 @@ pub async fn setup_email( } } - let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&json.email.to_lowercase()).expect("email validated by json struct"), - data.email_blocklist(), - )?; - let password = json.password.clone(); let pepper = data.password_hash_pepper().cloned(); @@ -521,7 +522,7 @@ pub async fn setup_email( // TODO: maybe make a good looking email template sometime in the future let html = html! { - (partials::salute(&developer.display_name)) + (partials::salute()) p { "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." } @@ -575,7 +576,7 @@ struct VerifyEmailConfigurationPayload { request_body = VerifyEmailConfigurationPayload, tag = "developers", responses( - (status = 200, description = "Email settings applied", body = inline(ApiResponse)), + (status = 204, description = "Email settings applied"), (status = 401, description = "Unauthorized"), (status = 404, description = "Setup request not found"), (status = 409, description = "Email already configured") diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index b4a21d8..b4720b9 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -52,6 +52,8 @@ pub async fn start_email_change( json: web::Json, data: web::Data, ) -> Result { + json.validate()?; + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; let front_url = data.front_url(); @@ -62,6 +64,12 @@ pub async fn start_email_change( let developer = auth.developer()?; let now = Utc::now(); + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email.to_lowercase()) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))?, + data.email_blocklist(), + )?; + { let mut conn = data.db().acquire().await?; @@ -91,18 +99,13 @@ pub async fn start_email_change( repository::email_change_requests::delete(developer.id, &mut conn).await?; } - if existing_email_login.email.to_lowercase() == json.email.to_lowercase() { + if existing_email_login.email.to_lowercase() == email.email().to_string().to_lowercase() { return Err(ApiError::BadRequest( "Your new email cannot be the same as your current email".into(), )); } } - let email = ApprovedEmailAddress::parse( - EmailAddress::from_str(&json.email.to_lowercase()).expect("email validated by json struct"), - data.email_blocklist(), - )?; - let expiry_date = now.add(Duration::minutes(30)); let uuid = Uuid::new_v4(); @@ -111,9 +114,9 @@ pub async fn start_email_change( // TODO: maybe make a good looking email template sometime in the future let html = html! { - (partials::salute(&developer.display_name)) + (partials::salute()) p { - "Someone (hopefully you) requested to change the email address for your Geode SDK developer account to this one." + "Someone (hopefully you) requested to change the email address for the Geode SDK developer account " (&developer.username) " to this one." } p { "If you didn't request this, you can safely ignore this email." @@ -164,7 +167,7 @@ struct FinalizeEmailChange { request_body = FinalizeEmailChange, tag = "developers", responses( - (status = 200, description = "Email changed", body = inline(ApiResponse)), + (status = 204, description = "Email changed"), (status = 401, description = "Unauthorized"), (status = 404, description = "Change request not found"), (status = 409, description = "You don't actually an email setup or you already have the requested address (should never happen)") @@ -211,7 +214,7 @@ pub async fn finalize_email_change( if existing.is_some_and(|e| e.id != developer.id) { return Err(ApiError::Conflict( - "You already have the requested email address! Congratulations!".into(), + "Someone else already has this email address".into(), )); } @@ -229,7 +232,6 @@ pub async fn finalize_email_change( )?; repository::developers::change_email(developer.id, ¤t_email, &email, &mut tx).await?; - repository::email_change_requests::delete(developer.id, &mut tx).await?; tx.commit().await?; From db4a8e0590aa9c4ecc474b110c775d3d313a828a Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:47:17 +0300 Subject: [PATCH 36/43] feat: properly parse email domains --- Cargo.lock | 1 + Cargo.toml | 1 + src/email/blocklist.rs | 87 ++++++++++++++++++++++++++++++++++++++---- src/email/mod.rs | 9 ++++- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89bbd8e..a2c04b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1650,6 +1650,7 @@ dependencies = [ "dotenvy", "futures", "html2text", + "idna", "image", "lettre", "maud", diff --git a/Cargo.toml b/Cargo.toml index a3714c8..500ee7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ rust-s3 = { git = "https://github.com/w4nderlust/rust-s3", rev = "2936eb7", defa ] } maud = "0.27.0" phc = "0.6.1" +idna = "1.1.0" [profile.release] lto = true diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index 47094f6..f69001f 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use idna::{AsciiDenyList, domain_to_ascii_cow}; use parking_lot::RwLock; use crate::email::{EmailAddress, EmailError}; @@ -25,7 +26,7 @@ impl ApprovedEmailAddress { pub fn parse(email: EmailAddress, blocklist: Option<&Blocklist>) -> Result { match blocklist { Some(blocklist) => { - if blocklist.is_blocked(&email.domain) { + if blocklist.is_blocked(&email) { Err(EmailError::BlocklistError(BlocklistError::BlockedDomain)) } else { Ok(Self(email)) @@ -63,8 +64,23 @@ impl Blocklist { } } - pub fn is_blocked(&self, domain: &str) -> bool { - self.entries.read().contains(domain) + pub fn is_blocked(&self, email: &EmailAddress) -> bool { + let domain = email.domain.as_str(); + let mut rest = domain; + + let entries = self.entries.read(); + + loop { + if entries.contains(rest) { + return true; + } + + // Yeah this also checks the TLD against the blocklist, whatever + match rest.split_once('.') { + Some((_, parent)) => rest = parent, + None => return false, + } + } } pub fn refresh(&self) { @@ -93,13 +109,70 @@ impl Blocklist { let content = std::fs::read_to_string(path)?; - let entries = content + Ok(Blocklist::parse_entries(&content)) + } + + fn parse_entries(content: &str) -> HashSet { + content .lines() .map(str::trim) .filter(|&line| !line.is_empty() && !line.starts_with('#')) - .map(str::to_lowercase) - .collect(); + .filter_map( + |line| match domain_to_ascii_cow(line.as_bytes(), AsciiDenyList::STD3) { + Ok(d) => Some(d.trim_end_matches('.').to_owned()), + Err(_) => { + tracing::warn!("skipping invalid blocklist entry: {line}"); + None + } + }, + ) + .filter(|d| !d.is_empty()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use parking_lot::lock_api::RwLock; + use std::str::FromStr; + + use super::*; + + impl Blocklist { + fn from_str_entries(content: &str) -> Self { + Self { + entries: Arc::new(RwLock::new(Self::parse_entries(content))), + path: Arc::new(PathBuf::new()), + } + } + } + + #[test] + fn test_unicode_domain_parsed_as_ascii() { + let entries = Blocklist::parse_entries("よう.jp"); + + assert!(entries.contains("xn--p8j3g.jp")); + } + + #[test] + fn test_domains_are_lowercased() { + let entries = Blocklist::parse_entries("ExAmPLE.cOm"); + + assert!(entries.contains("example.com")); + } + + #[test] + fn test_subdomain_blocked() { + let list = Blocklist::from_str_entries("example.com"); + let email = EmailAddress::from_str("test@a.example.com").unwrap(); + assert!(list.is_blocked(&email)); + } + + #[test] + fn test_multiple_line_blocklist() { + let entries = Blocklist::parse_entries("example.com\ntest.com"); - Ok(entries) + assert!(entries.contains("example.com")); + assert!(entries.contains("test.com")); } } diff --git a/src/email/mod.rs b/src/email/mod.rs index 7c9314a..1951741 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -2,6 +2,7 @@ use std::fmt::Display; use std::str::FromStr; use anyhow::anyhow; +use idna::AsciiDenyList; use validator::ValidateEmail; use crate::email::blocklist::BlocklistError; @@ -31,6 +32,7 @@ pub struct EmailAddress { domain: String, } +#[derive(PartialEq)] pub enum SmtpSecurity { None, StartTls, @@ -101,9 +103,14 @@ impl FromStr for EmailAddress { .split_once('@') .expect("validate_email already confirmed an '@' is present"); + let domain = idna::domain_to_ascii_cow(domain.as_bytes(), AsciiDenyList::STD3) + .map_err(|_| EmailAddressParseError::InvalidEmail)? + .trim_end_matches('.') + .to_owned(); + Ok(EmailAddress { local_part: local_part.into(), - domain: domain.into(), + domain, }) } } From cfb589a97c1d86f49c6ba3e30f67df6b5ec19f99 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:47:26 +0300 Subject: [PATCH 37/43] fix: register some routes --- src/main.rs | 2 ++ src/openapi.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index a0d29e6..342d7d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,6 +135,8 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::developers::ban_developer) .service(endpoints::developers::unban_developer) .service(endpoints::developers::get_developer_ban) + .service(endpoints::email_change_requests::start_email_change) + .service(endpoints::email_change_requests::finalize_email_change) .service(endpoints::tags::index) .service(endpoints::tags::detailed_index) .service(endpoints::stats::get_stats) diff --git a/src/openapi.rs b/src/openapi.rs index 78085f9..19f8d79 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -45,6 +45,8 @@ use crate::{endpoints, types}; endpoints::developers::ban_developer, endpoints::developers::unban_developer, endpoints::developers::get_developer_ban, + endpoints::email_change_requests::start_email_change, + endpoints::email_change_requests::finalize_email_change, endpoints::tags::index, endpoints::tags::detailed_index, endpoints::stats::get_stats, From 14a895e2908e0b2492ac7c221aa82900ec2b19d3 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 00:59:51 +0300 Subject: [PATCH 38/43] test: add more blocklist tests --- src/email/blocklist.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs index f69001f..27e30c6 100644 --- a/src/email/blocklist.rs +++ b/src/email/blocklist.rs @@ -175,4 +175,18 @@ mod tests { assert!(entries.contains("example.com")); assert!(entries.contains("test.com")); } + + #[test] + fn test_dont_block_substring() { + let list = Blocklist::from_str_entries("example.com"); + let email = EmailAddress::from_str("test@anexample.com").unwrap(); + assert!(!list.is_blocked(&email)); + } + + #[test] + fn test_dont_read_trailing_dot() { + let entries = Blocklist::parse_entries("example.com."); + + assert!(entries.contains("example.com")); + } } From f6bc7f07e22b790cb3fbf74cf9462bf70e52a95d Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 01:49:02 +0300 Subject: [PATCH 39/43] feat: send success email to old email on change --- src/email/mod.rs | 36 +++++++++++++++++++++++++- src/endpoints/email_change_requests.rs | 34 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/email/mod.rs b/src/email/mod.rs index 1951741..a0ddc40 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -1,8 +1,9 @@ -use std::fmt::Display; +use std::fmt::{Display, Write}; use std::str::FromStr; use anyhow::anyhow; use idna::AsciiDenyList; +use idna::uts46::Uts46; use validator::ValidateEmail; use crate::email::blocklist::BlocklistError; @@ -119,9 +120,42 @@ impl EmailAddress { pub fn local_part(&self) -> &str { &self.local_part } + + pub fn local_part_masked(&self) -> &str { + let shown = if self.local_part.chars().count() <= 2 { + 1 + } else { + 2 + }; + + let end = self.local_part.char_indices().nth(shown).map_or(self.local_part.len(), |(i, _)| i); + + &self.local_part[..end] + } + pub fn domain(&self) -> &str { &self.domain } + + pub fn to_masked_string(&self) -> String { + let (display, result) = Uts46::new().to_user_interface( + self.domain.as_bytes(), + AsciiDenyList::STD3, + idna::uts46::Hyphens::Allow, + |_label, _tld, _is_bidi| true, + ); + + let domain: &str = if result.is_ok() { display.as_ref() } else { &self.domain }; + let local_part_masked = self.local_part_masked(); + + let mut ret = format!("{}***@{}", local_part_masked, domain); + + if self.domain != domain { + write!(ret, " ({}***@{})", local_part_masked, self.domain).ok(); + } + + ret + } } impl Display for EmailAddress { diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index b4720b9..b83a52a 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -183,6 +183,7 @@ pub async fn finalize_email_change( json: web::Json, auth: Auth, ) -> Result { + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; let developer = auth.developer()?; let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { @@ -235,5 +236,38 @@ pub async fn finalize_email_change( tx.commit().await?; + let masked_email = email.email().to_masked_string(); + + let confirmation_email = html!( + (partials::salute()) + p { + "You have changed your email for your Geode SDK developer account " (&developer.username) " to " (&masked_email) "." + } + p { + "If you didn't request this, please contact an index admin immediately." + } + (partials::footer()) + ).into_string(); + + match EmailAddress::from_str(¤t_email) { + Ok(old) => { + let outgoing = OutgoingEmail { + // No need for a blocklist, this is an already approved email, also doesn't return Err + // without a blacklist + to: ApprovedEmailAddress::parse(old, None).unwrap(), + subject: "Email change successful | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(&confirmation_email) + .inspect_err(|e| tracing::error!("Failed to autogenerate text from html: {e}")) + .unwrap_or(EmailBody::Html(confirmation_email)) + }; + + let _ = mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e)); + }, + Err(e) => tracing::error!("stored email failed to parse, skipping change notification: {e}") + }; + Ok(HttpResponse::NoContent()) } From 076696f56ccf648f9bf6165c0b0357486ef99298 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 01:53:15 +0300 Subject: [PATCH 40/43] fix: wikipedia. --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index df8636c..84985c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,7 +72,7 @@ pub async fn build_config() -> anyhow::Result { if password_hash_pepper.is_none() { tracing::warn!( - "IMPORTANT: no password hash pepper found, password storage will be LESS secure. Check https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#peppering for more information." + "IMPORTANT: no password hash pepper found, password storage will be LESS secure. Check https://en.wikipedia.org/wiki/Pepper_(cryptography) for more information." ); } From 8e957a34b8baf029a630bdd1c2733fdf7559550e Mon Sep 17 00:00:00 2001 From: Fleeym Date: Thu, 8 Oct 2026 02:53:45 +0300 Subject: [PATCH 41/43] fix: dedupe password verify logic --- src/auth/password.rs | 59 +++++++++++++++++++++++++- src/endpoints/auth/database.rs | 41 +++--------------- src/endpoints/email_change_requests.rs | 33 +++++++------- src/endpoints/mod.rs | 6 +-- 4 files changed, 82 insertions(+), 57 deletions(-) diff --git a/src/auth/password.rs b/src/auth/password.rs index 1737017..f794ddc 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -1,4 +1,37 @@ -use argon2::{Algorithm, Argon2, Params, Version}; +use std::sync::OnceLock; + +use argon2::{Algorithm, Argon2, Params, PasswordHasher, PasswordVerifier, Version}; +use phc::PasswordHash; + + +#[derive(Debug, thiserror::Error)] +pub enum PasswordError { + #[error("stored password hash is malformed")] + MalformedHash(String), + #[error("password verification failed")] + Verify(String), + #[error("password task panicked")] + Join(#[from] tokio::task::JoinError) +} + +pub async fn verify(hash: String, password: String, pepper: Option) -> Result { + tokio::task::spawn_blocking(move || verify_blocking(&hash, &password, pepper.as_ref())) + .await + .inspect_err(|e| tracing::error!("tokio join error: {e}"))? +} + +/// Used to prevent timing attacks: still hash-check something, +/// but discard the result after +pub async fn verify_or_dummy(hash: Option, password: String, pepper: Option) -> Result { + tokio::task::spawn_blocking(move || { + let exists = hash.is_some(); + let hash = hash.as_deref().unwrap_or_else(|| dummy_hash(pepper.as_ref())); + + Ok(verify_blocking(hash, &password, pepper.as_ref())? && exists) + }) + .await + .inspect_err(|e| tracing::error!("tokio join error: {e}"))? +} pub fn argon2(pepper: Option<&String>) -> Argon2<'_> { match pepper { @@ -12,3 +45,27 @@ pub fn argon2(pepper: Option<&String>) -> Argon2<'_> { None => Argon2::default(), } } + +fn verify_blocking(hash: &str, password: &str, pepper: Option<&String>) -> Result { + let parsed_hash = PasswordHash::new(hash) + .inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}")) + .map_err(|_| PasswordError::MalformedHash(hash.into()))?; + + match argon2(pepper).verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::PasswordInvalid) => Ok(false), + Err(e) => Err(PasswordError::Verify(e.to_string())) + } +} + +fn dummy_hash(pepper: Option<&String>) -> &'static str { + static HASH: OnceLock = OnceLock::new(); + + HASH.get_or_init(|| { + argon2(pepper) + .hash_password(b"soggymod") + .expect("hashing a static str can't fail") + .to_string() + }) +} + diff --git a/src/endpoints/auth/database.rs b/src/endpoints/auth/database.rs index b591632..a1d1fc6 100644 --- a/src/endpoints/auth/database.rs +++ b/src/endpoints/auth/database.rs @@ -1,19 +1,13 @@ -use std::{str::FromStr, sync::OnceLock}; +use std::str::FromStr; use actix_web::{Responder, post, web}; -use argon2::{PasswordHash, PasswordHasher, PasswordVerifier}; use serde::Deserialize; use sqlx::Connection; use utoipa::ToSchema; use validator::Validate; use crate::{ - auth, - config::AppData, - database::repository::{auth_tokens, developers, refresh_tokens}, - email::EmailAddress, - endpoints::{ApiError, auth::TokensResponse}, - types::api::ApiResponse, + auth, config::AppData, database::repository::{auth_tokens, developers, refresh_tokens}, email::EmailAddress, endpoints::{ApiError, auth::TokensResponse}, types::api::ApiResponse, }; #[derive(ToSchema, Validate, Deserialize)] @@ -53,23 +47,9 @@ pub async fn login( let password_hash = login_data.as_ref().map(|data| &data.password_hash).cloned(); let pepper = data.password_hash_pepper().cloned(); - tokio::task::spawn_blocking(move || { - // Run the hash check anyway to reduce timing attack probability. - // Of course, someone putting in "soggymod" would pass this (if no dev exists with given email) - // but will fail when login data is unwrapped. - let password_hash = password_hash.unwrap_or_else(|| dummy_hash(pepper.as_ref()).to_owned()); - - let parsed_hash = PasswordHash::new(&password_hash) - .inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}")) - .map_err(|_| ApiError::InternalError("couldn't read user password".into()))?; - - auth::password::argon2(pepper.as_ref()) - .verify_password(json.password.as_bytes(), &parsed_hash) - .map_err(|_| ApiError::BadRequest("No user found with these credentials".into())) - }) - .await - .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) - .map_err(|_| ApiError::InternalError("hash check task panicked".into()))??; + if !auth::password::verify_or_dummy(password_hash, json.password.clone(), pepper).await? { + return Err(ApiError::BadRequest("No user found with these credentials".into())); + } let login_data = login_data.ok_or(ApiError::BadRequest( "No user found with these credentials".into(), @@ -91,14 +71,3 @@ pub async fn login( }, })) } - -fn dummy_hash(pepper: Option<&String>) -> &'static str { - static HASH: OnceLock = OnceLock::new(); - - HASH.get_or_init(|| { - auth::password::argon2(pepper) - .hash_password(b"soggymod") - .expect("hashing a static str can't fail") - .to_string() - }) -} diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index b83a52a..54555d9 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -10,23 +10,19 @@ use uuid::Uuid; use validator::Validate; use crate::{ - config::AppData, - database::repository, - email::{ + auth, config::AppData, database::repository, email::{ EmailAddress, blocklist::ApprovedEmailAddress, mailer::{EmailBody, OutgoingEmail}, partials, - }, - endpoints::ApiError, - extractors::auth::Auth, - types::api::ApiResponse, + }, endpoints::ApiError, extractors::auth::Auth, types::api::ApiResponse, }; #[derive(Deserialize, Validate, ToSchema)] struct StartEmailChangePayload { #[validate(email, length(max = 512))] email: String, + password: String, force: Option, } @@ -36,7 +32,7 @@ struct StartEmailChangePayload { tag = "developers", responses( (status = 201, description = "Request created", body = inline(ApiResponse)), - (status = 400, description = "New email is same as old email"), + (status = 400, description = "New email is same as old email / Invalid password provided"), (status = 401, description = "Unauthorized"), (status = 404, description = "Setup request not found"), (status = 409, description = "You already have a pending request, retry with \"force\" in JSON to confirm / Email address is not setup") @@ -70,7 +66,7 @@ pub async fn start_email_change( data.email_blocklist(), )?; - { + let existing_email_login = { let mut conn = data.db().acquire().await?; let existing_email_login = @@ -91,12 +87,8 @@ pub async fn start_email_change( if !force && exists_and_is_valid { return Err(ApiError::Conflict( - "You already have a pending email change request. Please confirm you want to invalidate it.".into(), - )); - } - - if exists.is_some() { - repository::email_change_requests::delete(developer.id, &mut conn).await?; + "You already have a pending email change request. Please confirm you want to invalidate it.".into(), + )); } if existing_email_login.email.to_lowercase() == email.email().to_string().to_lowercase() { @@ -104,12 +96,19 @@ pub async fn start_email_change( "Your new email cannot be the same as your current email".into(), )); } + + existing_email_login + }; + + let password_hash = existing_email_login.password_hash.clone(); + let pepper = data.password_hash_pepper().cloned(); + + if !auth::password::verify(password_hash, json.password.clone(), pepper).await? { + return Err(ApiError::BadRequest("Invalid password".into())); } let expiry_date = now.add(Duration::minutes(30)); - let uuid = Uuid::new_v4(); - let endpoint = format!("{}/email/change/verify?token={}", data.front_url(), uuid); // TODO: maybe make a good looking email template sometime in the future diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index abc01cb..c6aede4 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,7 +1,5 @@ use crate::{ - email::mailer::MailerError, - mod_zip::ModZipError, - types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, + auth::password::PasswordError, email::mailer::MailerError, mod_zip::ModZipError, types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; use actix_web::{HttpResponse, http::StatusCode}; use validator::{ValidationError, ValidationErrors}; @@ -62,6 +60,8 @@ pub enum ApiError { Mailer(#[from] MailerError), #[error("You are banned from accessing this resource: {}", .0.as_deref().unwrap_or("No reason provided"))] Banned(Option), + #[error("{0}")] + Password(#[from] PasswordError) } impl ApiError { From b226b4506a495a4ef39795ad6bddcf36927f394d Mon Sep 17 00:00:00 2001 From: Fleeym Date: Sun, 11 Oct 2026 02:02:22 +0300 Subject: [PATCH 42/43] feat: move templates to separate directory --- src/email/mod.rs | 13 +++++- src/email/templates/change_email.rs | 58 ++++++++++++++++++++++++++ src/email/templates/mod.rs | 1 + src/endpoints/developers.rs | 24 +---------- src/endpoints/email_change_requests.rs | 54 ++++++++---------------- 5 files changed, 89 insertions(+), 61 deletions(-) create mode 100644 src/email/templates/change_email.rs create mode 100644 src/email/templates/mod.rs diff --git a/src/email/mod.rs b/src/email/mod.rs index a0ddc40..a126464 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -12,6 +12,7 @@ pub mod blocklist; pub mod lettre; pub mod mailer; pub mod partials; +pub mod templates; #[derive(Debug, thiserror::Error)] pub enum EmailError { @@ -128,7 +129,11 @@ impl EmailAddress { 2 }; - let end = self.local_part.char_indices().nth(shown).map_or(self.local_part.len(), |(i, _)| i); + let end = self + .local_part + .char_indices() + .nth(shown) + .map_or(self.local_part.len(), |(i, _)| i); &self.local_part[..end] } @@ -145,7 +150,11 @@ impl EmailAddress { |_label, _tld, _is_bidi| true, ); - let domain: &str = if result.is_ok() { display.as_ref() } else { &self.domain }; + let domain: &str = if result.is_ok() { + display.as_ref() + } else { + &self.domain + }; let local_part_masked = self.local_part_masked(); let mut ret = format!("{}***@{}", local_part_masked, domain); diff --git a/src/email/templates/change_email.rs b/src/email/templates/change_email.rs new file mode 100644 index 0000000..2154b20 --- /dev/null +++ b/src/email/templates/change_email.rs @@ -0,0 +1,58 @@ +use maud::{Markup, html}; + +use crate::email::partials; + +pub fn setup_email(endpoint: &str) -> Markup { + html! { + (partials::salute()) + p { + "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." + } + p { + "If you didn't request this, you can safely ignore this email. No changes will be made to your account." + } + p { + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address" + } + p { + "This link expires in 30 minutes." + } + (partials::footer()) + } +} + +pub fn start_request(username: &str, endpoint: &str) -> Markup { + html! { + (partials::salute()) + p { + "Someone (hopefully you) requested to change the email address for the Geode SDK developer account " (username) " to this one." + } + p { + "If you didn't request this, you can safely ignore this email." + } + p { + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address." + } + p { + "This link expires in 30 minutes." + } + (partials::footer()) + } +} + +pub fn confirmation(username: &str, new_email: &str) -> Markup { + html!( + (partials::salute()) + p { + "You have changed your email for your Geode SDK developer account " (username) " to " (new_email) "." + } + p { + "If you didn't request this, please contact an index admin immediately." + } + (partials::footer()) + ) +} diff --git a/src/email/templates/mod.rs b/src/email/templates/mod.rs new file mode 100644 index 0000000..0a1033d --- /dev/null +++ b/src/email/templates/mod.rs @@ -0,0 +1 @@ +pub mod change_email; diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 9764dbd..800ccaf 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,7 +1,6 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use argon2::PasswordHasher; use chrono::{DateTime, Utc}; -use maud::html; use phc::PasswordHash; use serde::{Deserialize, Serialize}; use sqlx::Acquire; @@ -18,7 +17,7 @@ use crate::database::repository::{ }; use crate::email::blocklist::ApprovedEmailAddress; use crate::email::mailer::{EmailBody, OutgoingEmail}; -use crate::email::{EmailAddress, partials}; +use crate::email::{EmailAddress, templates}; use crate::types::api::{ApiResponse, PaginatedData}; use crate::types::models::developer::SelfDeveloper; use crate::{ @@ -520,26 +519,7 @@ pub async fn setup_email( let endpoint = format!("{}/email/setup/verify?token={}", data.front_url(), uuid); - // TODO: maybe make a good looking email template sometime in the future - let html = html! { - (partials::salute()) - p { - "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." - } - p { - "If you didn't request this, you can safely ignore this email. No changes will be made to your account." - } - p { - "Visit" - a href=(endpoint) { (endpoint) } - "to confirm this email address" - } - p { - "This link expires in 30 minutes." - } - (partials::footer()) - } - .into_string(); + let html = templates::change_email::setup_email(&endpoint).into_string(); let outgoing = OutgoingEmail { to: email.clone(), diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index 54555d9..ad5e9a9 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -2,7 +2,6 @@ use std::{ops::Add, str::FromStr}; use actix_web::{HttpResponse, Responder, post, web}; use chrono::{Duration, Utc}; -use maud::html; use serde::Deserialize; use sqlx::Connection; use utoipa::ToSchema; @@ -10,12 +9,18 @@ use uuid::Uuid; use validator::Validate; use crate::{ - auth, config::AppData, database::repository, email::{ + auth, + config::AppData, + database::repository, + email::{ EmailAddress, blocklist::ApprovedEmailAddress, mailer::{EmailBody, OutgoingEmail}, - partials, - }, endpoints::ApiError, extractors::auth::Auth, types::api::ApiResponse, + templates::change_email, + }, + endpoints::ApiError, + extractors::auth::Auth, + types::api::ApiResponse, }; #[derive(Deserialize, Validate, ToSchema)] @@ -111,26 +116,7 @@ pub async fn start_email_change( let uuid = Uuid::new_v4(); let endpoint = format!("{}/email/change/verify?token={}", data.front_url(), uuid); - // TODO: maybe make a good looking email template sometime in the future - let html = html! { - (partials::salute()) - p { - "Someone (hopefully you) requested to change the email address for the Geode SDK developer account " (&developer.username) " to this one." - } - p { - "If you didn't request this, you can safely ignore this email." - } - p { - "Visit" - a href=(endpoint) { (endpoint) } - "to confirm this email address." - } - p { - "This link expires in 30 minutes." - } - (partials::footer()) - } - .into_string(); + let html = change_email::start_request(&developer.username, &endpoint).into_string(); let outgoing = OutgoingEmail { to: email.clone(), @@ -237,16 +223,8 @@ pub async fn finalize_email_change( let masked_email = email.email().to_masked_string(); - let confirmation_email = html!( - (partials::salute()) - p { - "You have changed your email for your Geode SDK developer account " (&developer.username) " to " (&masked_email) "." - } - p { - "If you didn't request this, please contact an index admin immediately." - } - (partials::footer()) - ).into_string(); + let confirmation_email = + change_email::confirmation(&developer.username, &masked_email).into_string(); match EmailAddress::from_str(¤t_email) { Ok(old) => { @@ -257,15 +235,17 @@ pub async fn finalize_email_change( subject: "Email change successful | Geode SDK".into(), body: EmailBody::html_with_autogenerated_text(&confirmation_email) .inspect_err(|e| tracing::error!("Failed to autogenerate text from html: {e}")) - .unwrap_or(EmailBody::Html(confirmation_email)) + .unwrap_or(EmailBody::Html(confirmation_email)), }; let _ = mailer .send(&outgoing) .await .inspect_err(|e| tracing::error!("{:?}", e)); - }, - Err(e) => tracing::error!("stored email failed to parse, skipping change notification: {e}") + } + Err(e) => { + tracing::error!("stored email failed to parse, skipping change notification: {e}") + } }; Ok(HttpResponse::NoContent()) From 4e9528d18bcf246985dcfdd652acada8d43b2f13 Mon Sep 17 00:00:00 2001 From: Fleeym Date: Sun, 11 Oct 2026 02:23:15 +0300 Subject: [PATCH 43/43] fix: string cleanup --- src/endpoints/developers.rs | 23 +++++++++-------------- src/endpoints/email_change_requests.rs | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index 800ccaf..afa1d2b 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -478,7 +478,7 @@ pub async fn setup_email( if existing_email_login.is_some() { return Err(ApiError::Conflict( - "You already have email login setup. Use /v1/me/email/change for changing your email or /v1/me/password/change for changing your password".into(), + "You already have a configured email address. Use /v1/me/email/change for changing your email or /v1/me/password/change for changing your password".into(), )); } @@ -496,7 +496,7 @@ pub async fn setup_email( if existing.is_some() && !force { return Err(ApiError::Conflict( - "user already has an active email setup request".into(), + "User already has an active email setup request".into(), )); } } @@ -576,26 +576,21 @@ pub async fn verify_email_setup( let developer = auth.developer()?; - let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { - ApiError::NotFound( - "The request associated with this ID either does not exist or has expired".into(), - ) - })?; + let notfound_msg = "The request associated with this ID either does not exist or has expired"; + + let request_id = + Uuid::try_parse(&json.request_id).map_err(|_| ApiError::NotFound(notfound_msg.into()))?; let mut conn = data.db().acquire().await?; let mut tx = conn.begin().await?; let request = email_setup_requests::find_for_token(request_id, &mut tx) .await? - .ok_or(ApiError::NotFound( - "The request associated with this ID either does not exist or has expired".into(), - ))?; + .ok_or(ApiError::NotFound(notfound_msg.into()))?; if request.developer_id != developer.id { // Let's be a little sneaky here... >:) - return Err(ApiError::NotFound( - "The request associated with this ID either does not exist or has expired".into(), - )); + return Err(ApiError::NotFound(notfound_msg.into())); } let existing = developers::find_by_email(&request.email, &mut tx).await?; @@ -627,7 +622,7 @@ pub async fn verify_email_setup( email_setup_requests::delete(&request.token, &mut tx).await?; tx.commit().await?; - return Err(ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again".into())); + return Err(ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again.".into())); } developers::finalize_email_setup(developer.id, &email, &password, &mut tx).await?; diff --git a/src/endpoints/email_change_requests.rs b/src/endpoints/email_change_requests.rs index ad5e9a9..10985bc 100644 --- a/src/endpoints/email_change_requests.rs +++ b/src/endpoints/email_change_requests.rs @@ -67,7 +67,7 @@ pub async fn start_email_change( let email = ApprovedEmailAddress::parse( EmailAddress::from_str(&json.email.to_lowercase()) - .map_err(|_| ApiError::BadRequest("invalid email address".into()))?, + .map_err(|_| ApiError::BadRequest("Invalid email address".into()))?, data.email_blocklist(), )?;