From 494aaa1dc53beab26f01970a9f4211579720b778 Mon Sep 17 00:00:00 2001 From: v0l Date: Mon, 15 Jun 2026 12:22:57 +0100 Subject: [PATCH 1/2] feat: add email_hash column for indexed user email lookup - Add email_hash BINARY(32) column with index to users table - Add email_hash() utility using SHA-256 of lowercased+trimmed email - update_user() automatically computes and stores email_hash - Add GET /api/admin/v1/users/by-email endpoint using indexed lookup - Add data migration to backfill email_hash for existing users at startup --- .../src/data_migration/email_hash_backfill.rs | 79 +++++++++++++++++++ lnvps_api/src/data_migration/mod.rs | 5 ++ lnvps_api_admin/src/admin/users.rs | 28 ++++++- lnvps_api_admin/src/bin/generate_demo_data.rs | 1 + lnvps_api_common/src/mock.rs | 9 ++- lnvps_db/Cargo.toml | 1 + .../20260615000000_add_email_hash.sql | 4 + lnvps_db/src/admin.rs | 4 + lnvps_db/src/lib.rs | 11 +++ lnvps_db/src/model.rs | 2 + lnvps_db/src/mysql.rs | 58 +++++++++++++- 11 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 lnvps_api/src/data_migration/email_hash_backfill.rs create mode 100644 lnvps_db/migrations/20260615000000_add_email_hash.sql diff --git a/lnvps_api/src/data_migration/email_hash_backfill.rs b/lnvps_api/src/data_migration/email_hash_backfill.rs new file mode 100644 index 00000000..adcaa5be --- /dev/null +++ b/lnvps_api/src/data_migration/email_hash_backfill.rs @@ -0,0 +1,79 @@ +use crate::data_migration::DataMigration; +use anyhow::Result; +use lnvps_db::{EncryptionContext, LNVpsDb}; +use log::info; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +pub struct EmailHashBackfillMigration { + db: Arc, +} + +impl EmailHashBackfillMigration { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +impl DataMigration for EmailHashBackfillMigration { + fn migrate(&self) -> Pin> + Send>> { + let db = self.db.clone(); + Box::pin(async move { + // Find all users with an email but no email_hash yet + let rows = db + .fetch_raw_strings( + "SELECT id, email FROM users WHERE email IS NOT NULL AND email != '' AND email_hash IS NULL", + ) + .await?; + + if rows.is_empty() { + info!("No users need email_hash backfill"); + return Ok(()); + } + + info!("Backfilling email_hash for {} users", rows.len()); + + let encryption_context = EncryptionContext::get()?; + let mut updated = 0u64; + let mut skipped = 0u64; + + for (user_id, email_encrypted) in &rows { + // Decrypt the email + let email_plaintext = if EncryptionContext::is_encrypted(email_encrypted) { + match encryption_context.decrypt(email_encrypted) { + Ok(plain) => plain, + Err(e) => { + info!("Failed to decrypt email for user {}: {}", user_id, e); + skipped += 1; + continue; + } + } + } else { + email_encrypted.clone() + }; + + let hash = lnvps_db::email_hash(&email_plaintext); + let hash_hex = hex::encode(hash); + + db.execute_query_with_string_params( + "UPDATE users SET email_hash = UNHEX(?) WHERE id = ?", + vec![hash_hex, user_id.to_string()], + ) + .await?; + + updated += 1; + if updated % 100 == 0 { + info!("Email hash backfill progress: {} updated, {} skipped", updated, skipped); + } + } + + info!( + "Email hash backfill complete: {} updated, {} skipped", + updated, skipped + ); + + Ok(()) + }) + } +} diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs index 7f6d24e0..65f0bf35 100644 --- a/lnvps_api/src/data_migration/mod.rs +++ b/lnvps_api/src/data_migration/mod.rs @@ -1,5 +1,6 @@ use crate::data_migration::arp_ref_fixer::ArpRefFixerDataMigration; use crate::data_migration::dns::DnsDataMigration; +use crate::data_migration::email_hash_backfill::EmailHashBackfillMigration; use crate::data_migration::encryption_migration::EncryptionDataMigration; use crate::data_migration::ip6_init::Ip6InitDataMigration; use crate::data_migration::payment_method_config::PaymentMethodConfigMigration; @@ -15,6 +16,7 @@ use std::sync::Arc; mod arp_ref_fixer; mod dns; +mod email_hash_backfill; mod encryption_migration; mod ip6_init; mod payment_method_config; @@ -55,6 +57,9 @@ pub async fn run_data_migrations( // Migrate SSH key from proxmox config to database migrations.push(Box::new(SshKeyMigration::new(db.clone(), settings.clone()))); + // Backfill email_hash for users missing it (must run after encryption migration) + migrations.push(Box::new(EmailHashBackfillMigration::new(db.clone()))); + info!("Running {} data migrations", migrations.len()); for migration in migrations { if let Err(e) = migration.migrate().await { diff --git a/lnvps_api_admin/src/admin/users.rs b/lnvps_api_admin/src/admin/users.rs index 1f5f2733..996e9df1 100644 --- a/lnvps_api_admin/src/admin/users.rs +++ b/lnvps_api_admin/src/admin/users.rs @@ -6,12 +6,16 @@ use axum::routing::get; use axum::{Json, Router}; use isocountry::CountryCode; use lnvps_api_common::{ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; -use lnvps_db::{AdminAction, AdminResource}; +use lnvps_db::{AdminAction, AdminResource, email_hash}; use serde::Deserialize; pub fn router() -> Router { Router::new() .route("/api/admin/v1/users", get(admin_list_users)) + .route( + "/api/admin/v1/users/by-email", + get(admin_find_user_by_email), + ) .route( "/api/admin/v1/users/{id}", get(admin_get_user).patch(admin_update_user), @@ -77,6 +81,28 @@ async fn admin_list_users( ) } +#[derive(Deserialize)] +struct FindByEmailQuery { + email: String, +} + +/// Find a single user by their email address using the indexed email_hash column. +async fn admin_find_user_by_email( + auth: AdminAuth, + State(this): State, + Query(query): Query, +) -> ApiResult { + auth.require_permission(AdminResource::Users, AdminAction::View)?; + + let hash = email_hash(&query.email); + let user = this.db.admin_find_user_by_email_hash(&hash).await?; + + match user { + Some(u) => ApiData::ok(u.into()), + None => ApiData::err("User not found"), + } +} + /// Update user account information async fn admin_update_user( auth: AdminAuth, diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index d66fba4e..2196d407 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1083,6 +1083,7 @@ async fn create_users(db: &LNVpsDbMysql) -> Result> { billing_postcode: None, billing_tax_id: None, nwc_connection_string: None, + email_hash: None, }; let pubkey_array: [u8; 32] = pubkey_bytes.as_slice().try_into()?; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 92a16c1c..51257ce2 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -299,6 +299,7 @@ impl LNVpsDbBase for MockDb { let mut users = self.users.lock().await; if let Some(u) = users.get_mut(&user.id) { u.email = user.email.clone(); + u.email_hash = user.email_hash.clone(); u.email_verified = user.email_verified; u.email_verify_token = user.email_verify_token.clone(); u.contact_email = user.contact_email; @@ -839,7 +840,9 @@ impl LNVpsDbBase for MockDb { if let Some(vm) = v.get_mut(&payment.vm_id) { // Un-delete the VM if it was deleted (e.g. auto-cleaned up before payment arrived) vm.deleted = false; - vm.expires = vm.expires.add(TimeDelta::seconds(payment.time_value as i64)); + vm.expires = vm + .expires + .add(TimeDelta::seconds(payment.time_value as i64)); } Ok(()) } @@ -1795,6 +1798,10 @@ impl lnvps_db::AdminDb for MockDb { Ok((paginated_users, total)) } + async fn admin_find_user_by_email_hash(&self, _hash: &[u8; 32]) -> DbResult> { + Ok(None) + } + async fn admin_list_regions( &self, limit: u64, diff --git a/lnvps_db/Cargo.toml b/lnvps_db/Cargo.toml index 0665ca43..a6932d16 100644 --- a/lnvps_db/Cargo.toml +++ b/lnvps_db/Cargo.toml @@ -22,6 +22,7 @@ sqlx = { version = "0.8", default-features = false, features = ["mysql", "macros chrono = { version = "0.4", features = ["serde"] } url = "2.5" aes-gcm = "0.10" +sha2 = "0.10" zeroize = { version = "1", features = ["derive"] } base64 = "0.22" uuid.workspace = true diff --git a/lnvps_db/migrations/20260615000000_add_email_hash.sql b/lnvps_db/migrations/20260615000000_add_email_hash.sql new file mode 100644 index 00000000..2e724b93 --- /dev/null +++ b/lnvps_db/migrations/20260615000000_add_email_hash.sql @@ -0,0 +1,4 @@ +-- Add email_hash column for efficient email lookup +-- email_hash is SHA-256 of lowercased+trimmed email, stored as 32-byte BINARY +ALTER TABLE users ADD COLUMN email_hash BINARY(32) DEFAULT NULL; +CREATE INDEX idx_users_email_hash ON users(email_hash); diff --git a/lnvps_db/src/admin.rs b/lnvps_db/src/admin.rs index abc89da5..1d4924c6 100644 --- a/lnvps_db/src/admin.rs +++ b/lnvps_db/src/admin.rs @@ -68,6 +68,10 @@ pub trait AdminDb: Send + Sync { search_pubkey: Option<&str>, ) -> DbResult<(Vec, u64)>; + /// Find a user by their email hash (SHA-256 of lowercased+trimmed email). + /// Returns None if no match found. + async fn admin_find_user_by_email_hash(&self, hash: &[u8; 32]) -> DbResult>; + // Region management methods /// List all regions with pagination async fn admin_list_regions( diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 891f029a..b0877c97 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -26,6 +26,17 @@ pub use model::*; pub use mysql::*; use try_procedure::OpError; +/// Compute the email hash used for lookups: SHA-256 of lowercased, trimmed email. +pub fn email_hash(email: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(email.trim().to_lowercase().as_bytes()); + let result = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + #[derive(Error, Debug)] pub enum DbError { #[error("sqlx: {0}")] diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index ad2d9291..8455662c 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -20,6 +20,8 @@ pub struct User { pub created: DateTime, /// Users email address for notifications (encrypted) pub email: EncryptedString, + /// SHA-256 hash of lowercased+trimmed email for lookups (32 bytes) + pub email_hash: Option>, /// Whether the email address has been verified pub email_verified: bool, /// Token used for email address verification (empty string means no pending verification) diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index ccf624a8..b7283994 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -63,10 +63,16 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn update_user(&self, user: &User) -> DbResult<()> { + let hash = if user.email.is_empty() { + None + } else { + Some(crate::email_hash(user.email.as_str()).to_vec()) + }; sqlx::query( - "update users set email=?, email_verified=?, email_verify_token=?, contact_nip17=?, contact_email=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=?, nwc_connection_string=? where id = ?", + "update users set email=?, email_hash=?, email_verified=?, email_verify_token=?, contact_nip17=?, contact_email=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=?, nwc_connection_string=? where id = ?", ) .bind(&user.email) + .bind(hash) .bind(user.email_verified) .bind(&user.email_verify_token) .bind(user.contact_nip17) @@ -2468,6 +2474,7 @@ impl AdminDb for LNVpsDbMysql { u.pubkey, u.created, u.email, + u.email_hash, u.email_verified, u.email_verify_token, u.contact_nip17, @@ -2529,6 +2536,55 @@ impl AdminDb for LNVpsDbMysql { Ok((users, total)) } + async fn admin_find_user_by_email_hash(&self, hash: &[u8; 32]) -> DbResult> { + let user = sqlx::query_as::<_, crate::AdminUserInfo>( + r#" + SELECT + u.id, + u.pubkey, + u.created, + u.email, + u.email_hash, + u.email_verified, + u.email_verify_token, + u.contact_nip17, + u.contact_email, + u.country_code, + u.billing_name, + u.billing_address_1, + u.billing_address_2, + u.billing_city, + u.billing_state, + u.billing_postcode, + u.billing_tax_id, + u.nwc_connection_string, + COALESCE(vm_stats.vm_count, 0) as vm_count, + CASE WHEN admin_roles.user_id IS NOT NULL THEN 1 ELSE 0 END as is_admin + FROM users u + LEFT JOIN ( + SELECT + user_id, + COUNT(*) as vm_count + FROM vm + WHERE deleted = 0 + GROUP BY user_id + ) vm_stats ON u.id = vm_stats.user_id + LEFT JOIN ( + SELECT DISTINCT user_id + FROM admin_role_assignments + WHERE expires_at IS NULL OR expires_at > NOW() + ) admin_roles ON u.id = admin_roles.user_id + WHERE u.email_hash = ? + LIMIT 1 + "#, + ) + .bind(hash.as_slice()) + .fetch_optional(&self.db) + .await?; + + Ok(user) + } + async fn admin_list_regions( &self, limit: u64, From ee7b6ce08e94f2a5d82dd43fde669961dc0be69a Mon Sep 17 00:00:00 2001 From: v0l Date: Mon, 15 Jun 2026 12:33:48 +0100 Subject: [PATCH 2/2] chore: update Cargo.lock for sha2 dependency --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 776b9461..aec65cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2595,6 +2595,7 @@ dependencies = [ "log 0.4.29", "serde", "serde_json", + "sha2", "sqlx", "tempfile", "thiserror 2.0.18",