Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions lnvps_api/src/data_migration/email_hash_backfill.rs
Original file line number Diff line number Diff line change
@@ -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<dyn LNVpsDb>,
}

impl EmailHashBackfillMigration {
pub fn new(db: Arc<dyn LNVpsDb>) -> Self {
Self { db }
}
}

impl DataMigration for EmailHashBackfillMigration {
fn migrate(&self) -> Pin<Box<dyn Future<Output = Result<()>> + 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(())
})
}
}
5 changes: 5 additions & 0 deletions lnvps_api/src/data_migration/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion lnvps_api_admin/src/admin/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterState> {
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),
Expand Down Expand Up @@ -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<RouterState>,
Query(query): Query<FindByEmailQuery>,
) -> ApiResult<AdminUserInfo> {
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,
Expand Down
1 change: 1 addition & 0 deletions lnvps_api_admin/src/bin/generate_demo_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,7 @@ async fn create_users(db: &LNVpsDbMysql) -> Result<Vec<User>> {
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()?;
Expand Down
9 changes: 8 additions & 1 deletion lnvps_api_common/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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<Option<AdminUserInfo>> {
Ok(None)
}

async fn admin_list_regions(
&self,
limit: u64,
Expand Down
1 change: 1 addition & 0 deletions lnvps_db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lnvps_db/migrations/20260615000000_add_email_hash.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions lnvps_db/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ pub trait AdminDb: Send + Sync {
search_pubkey: Option<&str>,
) -> DbResult<(Vec<crate::AdminUserInfo>, 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<Option<crate::AdminUserInfo>>;

// Region management methods
/// List all regions with pagination
async fn admin_list_regions(
Expand Down
11 changes: 11 additions & 0 deletions lnvps_db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
2 changes: 2 additions & 0 deletions lnvps_db/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct User {
pub created: DateTime<Utc>,
/// Users email address for notifications (encrypted)
pub email: EncryptedString,
/// SHA-256 hash of lowercased+trimmed email for lookups (32 bytes)
pub email_hash: Option<Vec<u8>>,
/// Whether the email address has been verified
pub email_verified: bool,
/// Token used for email address verification (empty string means no pending verification)
Expand Down
58 changes: 57 additions & 1 deletion lnvps_db/src/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2529,6 +2536,55 @@ impl AdminDb for LNVpsDbMysql {
Ok((users, total))
}

async fn admin_find_user_by_email_hash(&self, hash: &[u8; 32]) -> DbResult<Option<crate::AdminUserInfo>> {
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,
Expand Down
Loading