a06673b
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<Vec<RecoveryCodeView>> {
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<RegenerateRecoveryResp> {
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<String> = 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::<String>().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<Vec<AccessKeyView>> {
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<CreateAccessKeyResp> {
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::<String>().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<Vec<SshKeyView>> {
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<SshKeyView> {
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<db::user::user_ssh_keys::SshKeyType> {
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<Vec<PushSubscriptionView>> {
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<PushSubscriptionView> {
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) {}