use base64::Engine; use db::conn::sea_orm::{ ActiveModelTrait, EntityTrait, QueryFilter, Set, }; use rand::TryRng; use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::error::{UserError, UserResult}; use crate::features::UserService; use super::types::{ AccessKeyScope, AccessKeyView, AddSshKeyCmd, CreateAccessKeyCmd, CreateAccessKeyResp, PushPlatform, PushSubscriptionView, RecoveryCodeView, RegenerateRecoveryResp, SshKeyType, SshKeyView, UpsertPushCmd, }; fn random_token(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::rng().try_fill_bytes(&mut buf).expect("rng"); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf) } fn sha256_hex(data: &[u8]) -> String { let mut h = Sha256::new(); h.update(data); let out = h.finalize(); out.iter().map(|b| format!("{:02x}", b)).collect() } // === recovery codes === pub async fn list_recovery(svc: &UserService, caller: Uuid) -> UserResult> { let db = svc.db.reader(); let rows = db::user::user_recovery_codes::Entity::find() .filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(RecoveryCodeView::from).collect()) } pub async fn regenerate_recovery( svc: &UserService, caller: Uuid, ) -> UserResult { let writer = svc.db.writer(); db::user::user_recovery_codes::Entity::delete_many() .filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller)) .exec(&writer) .await?; let max = svc.config.max_recovery_codes; let mut codes: Vec = Vec::with_capacity(max); let now = time::OffsetDateTime::now_utc(); for _ in 0..max { let raw = format!( "{}-{}", random_token(4), random_token(4), ); let raw = raw.replace('-', "").chars().take(10).collect::().to_uppercase(); let hash = sha256_hex(raw.as_bytes()); let active: db::user::user_recovery_codes::ActiveModel = db::user::user_recovery_codes::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_code_hash(hash) .set_used_at(None) .set_expires_at(None) .set_created_at(now) .into(); active.insert(&writer).await?; codes.push(raw); } Ok(RegenerateRecoveryResp { codes }) } // === access keys === pub async fn list_access_keys( svc: &UserService, caller: Uuid, ) -> UserResult> { let db = svc.db.reader(); let rows = db::user::user_access_keys::Entity::find() .filter(db::user::user_access_keys::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(AccessKeyView::from).collect()) } pub async fn create_access_key( svc: &UserService, caller: Uuid, cmd: CreateAccessKeyCmd, ) -> UserResult { let bytes = svc.config.access_key_token_bytes; let raw = random_token(bytes); let salt = random_token(16); let hash = sha256_hex(format!("{}{}", raw, salt).as_bytes()); let last_eight: String = raw.chars().rev().take(8).collect::().chars().rev().collect(); let now = time::OffsetDateTime::now_utc(); let writer = svc.db.writer(); let active: db::user::user_access_keys::ActiveModel = db::user::user_access_keys::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_name(cmd.name) .set_token_hash(hash) .set_token_salt(salt) .set_token_last_eight(last_eight) .set_scope(cmd.scope) .set_version(1) .set_created_at(now) .set_updated_at(now) .into(); let saved = active.insert(&writer).await?; Ok(CreateAccessKeyResp { key: AccessKeyView::from(saved), token: raw, }) } pub async fn delete_access_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> { let writer = svc.db.writer(); let row = db::user::user_access_keys::Entity::find_by_id(key_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("access key {key_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden("cannot delete other user's key".into())); } db::user::user_access_keys::Entity::delete_by_id(key_id) .exec(&writer) .await?; Ok(()) } // === ssh keys === pub async fn list_ssh_keys(svc: &UserService, caller: Uuid) -> UserResult> { let db = svc.db.reader(); let rows = db::user::user_ssh_keys::Entity::find() .filter(db::user::user_ssh_keys::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(SshKeyView::from).collect()) } pub async fn add_ssh_key( svc: &UserService, caller: Uuid, cmd: AddSshKeyCmd, ) -> UserResult { if cmd.name.is_empty() || cmd.name.len() > 64 { return Err(UserError::BadRequest("ssh key name length must be 1..=64".into())); } let detected = detect_ssh_key_type(&cmd.content).ok_or_else(|| { UserError::BadRequest("ssh key content malformed or unsupported".into()) })?; let key_type = cmd.key_type.map(Into::into).unwrap_or(detected); let fingerprint = sha256_hex(cmd.content.as_bytes()); let writer = svc.db.writer(); let now = time::OffsetDateTime::now_utc(); let active: db::user::user_ssh_keys::ActiveModel = db::user::user_ssh_keys::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_key_type(key_type) .set_fingerprint(fingerprint.clone()) .set_name(cmd.name) .set_content(cmd.content) .set_created_at(now) .set_updated_at(now) .set_last_used_at(None) .into(); let saved = active.insert(&writer).await?; Ok(SshKeyView::from(saved)) } pub async fn delete_ssh_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> { let writer = svc.db.writer(); let row = db::user::user_ssh_keys::Entity::find_by_id(key_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("ssh key {key_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden("cannot delete other user's ssh key".into())); } db::user::user_ssh_keys::Entity::delete_by_id(key_id) .exec(&writer) .await?; Ok(()) } fn detect_ssh_key_type(content: &str) -> Option { use db::user::user_ssh_keys::SshKeyType as K; if content.starts_with("ssh-rsa") { Some(K::SshRsa) } else if content.starts_with("ssh-ed25519") { Some(K::SshEd25519) } else if content.starts_with("ecdsa-sha2-nistp256") { Some(K::EcdsaSha2Nistp256) } else { None } } // === push subscriptions === pub async fn list_push(svc: &UserService, caller: Uuid) -> UserResult> { let db = svc.db.reader(); let rows = db::user::user_push_subscriptions::Entity::find() .filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(PushSubscriptionView::from).collect()) } pub async fn upsert_push( svc: &UserService, caller: Uuid, cmd: UpsertPushCmd, ) -> UserResult { if cmd.endpoint.is_empty() || cmd.endpoint.len() > 2048 { return Err(UserError::BadRequest("endpoint invalid".into())); } let writer = svc.db.writer(); let now = time::OffsetDateTime::now_utc(); let existing = db::user::user_push_subscriptions::Entity::find() .filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller)) .filter(db::user::user_push_subscriptions::COLUMN.endpoint.eq(&cmd.endpoint)) .one(&writer) .await?; let saved = if let Some(row) = existing { let mut am: db::user::user_push_subscriptions::ActiveModel = row.into(); am.p256dh_key = Set(cmd.p256dh_key); am.auth_key = Set(cmd.auth_key); am.platform = Set(cmd.platform.into()); am.user_agent = Set(cmd.user_agent); am.updated_at = Set(Some(now)); am.update(&writer).await? } else { let active: db::user::user_push_subscriptions::ActiveModel = db::user::user_push_subscriptions::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_endpoint(cmd.endpoint) .set_p256dh_key(cmd.p256dh_key) .set_auth_key(cmd.auth_key) .set_platform(cmd.platform) .set_user_agent(cmd.user_agent) .set_created_at(now) .set_updated_at(Some(now)) .into(); active.insert(&writer).await? }; Ok(PushSubscriptionView::from(saved)) } pub async fn delete_push(svc: &UserService, caller: Uuid, push_id: Uuid) -> UserResult<()> { let writer = svc.db.writer(); let row = db::user::user_push_subscriptions::Entity::find_by_id(push_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("push subscription {push_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden( "cannot delete other user's push subscription".into(), )); } db::user::user_push_subscriptions::Entity::delete_by_id(push_id) .exec(&writer) .await?; Ok(()) } #[allow(dead_code)] fn _use_unused(_: &AccessKeyScope, _: &SshKeyType, _: &PushPlatform) {}