use cot::auth::PasswordHash; use cot::common_types::Password; use cot::db::{Auto, Database, LimitedString, Model}; // --------------------------------------------------------------------------- // User model // --------------------------------------------------------------------------- #[derive(Debug, Clone)] #[cot::db::model] pub struct User { #[model(primary_key)] id: Auto, #[model(unique)] username: LimitedString<255>, password: Option, email: Option, display_name: Option, avatar_url: Option, telegram_id: Option, telegram_link_code: Option, telegram_link_code_created_at: Option, role: LimitedString<32>, is_active: bool, } // --------------------------------------------------------------------------- // User helper methods // --------------------------------------------------------------------------- impl User { /// List all users. pub async fn list_all(db: &Database) -> cot::db::Result> { Self::objects().all(db).await } /// Get a user by primary key. pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result> { Self::get_by_primary_key(db, Auto::Fixed(user_id)).await } /// Create a new user and insert it into the database. pub async fn create( db: &Database, username: &str, email: Option<&str>, display_name: Option<&str>, password: &str, role: &str, ) -> cot::db::Result { let hash = PasswordHash::from_password(&Password::new(password)); let mut user = Self { id: Auto::auto(), username: LimitedString::new(username).unwrap(), password: Some(hash.into_string()), email: email.map(str::to_owned), display_name: display_name.map(str::to_owned), avatar_url: None, telegram_id: None, telegram_link_code: None, telegram_link_code_created_at: None, role: LimitedString::new(role).unwrap(), is_active: true, }; user.insert(db).await?; Ok(user) } /// Create a user without a password (for OIDC-only accounts). pub async fn create_oidc( db: &Database, username: &str, email: Option<&str>, display_name: Option<&str>, role: &str, ) -> cot::db::Result { let mut user = Self { id: Auto::auto(), username: LimitedString::new(username).unwrap(), password: None, email: email.map(str::to_owned), display_name: display_name.map(str::to_owned), avatar_url: None, telegram_id: None, telegram_link_code: None, telegram_link_code_created_at: None, role: LimitedString::new(role).unwrap(), is_active: true, }; user.insert(db).await?; Ok(user) } /// Update an existing user. If `new_password` is `Some`, the password hash /// is replaced; otherwise the existing hash is kept. pub async fn update_fields( &mut self, db: &Database, username: &str, email: Option<&str>, display_name: Option<&str>, new_password: Option<&str>, role: &str, ) -> cot::db::Result<()> { self.username = LimitedString::new(username).unwrap(); self.email = email.map(str::to_owned); self.display_name = display_name.map(str::to_owned); if let Some(pw) = new_password { self.password = Some(PasswordHash::from_password(&Password::new(pw)).into_string()); } self.role = LimitedString::new(role).unwrap(); self.save(db).await } /// Look up a user by username. pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result> { let Ok(username) = LimitedString::<255>::new(username) else { return Ok(None); }; cot::db::query!(User, $username == username).get(db).await } /// Find a user by email address. pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result> { let email = email.to_owned(); cot::db::query!(User, $email == Some(email)).get(db).await } /// Find a user linked to a non-empty Telegram ID. pub async fn get_by_telegram_id( db: &Database, telegram_id: &str, ) -> cot::db::Result> { let telegram_id = telegram_id.trim(); if telegram_id.is_empty() { return Ok(None); } let telegram_id = telegram_id.to_owned(); cot::db::query!(User, $telegram_id == Some(telegram_id)) .get(db) .await } /// Find a user waiting for this Telegram link code. pub async fn get_by_telegram_link_code( db: &Database, code: &str, ) -> cot::db::Result> { let code = code.trim(); if code.is_empty() { return Ok(None); } let code = code.to_owned(); cot::db::query!(User, $telegram_link_code == Some(code)) .get(db) .await } /// Count all users in the database. pub async fn count_all(db: &Database) -> cot::db::Result { Self::objects().count(db).await } /// Return a reference to the password hash, if set. pub fn password_ref(&self) -> Option { self.password .as_ref() .and_then(|hash| PasswordHash::new(hash.clone()).ok()) } /// Parse the stored role code into a `Role`, defaulting to `Client`. pub fn role(&self) -> crate::auth::Role { crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::Client) } /// Update the user's role and persist the change. pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> { self.role = LimitedString::new(role).unwrap(); self.save(db).await } /// Store a Telegram link state. `Some("")` means the user declined linking. pub async fn set_telegram_id( &mut self, db: &Database, telegram_id: Option<&str>, ) -> cot::db::Result<()> { self.telegram_id = telegram_id.map(str::to_owned); self.save(db).await } /// Store or clear a pending Telegram link code. pub async fn set_telegram_link_code( &mut self, db: &Database, code: Option<&str>, created_at: Option, ) -> cot::db::Result<()> { self.telegram_link_code = code.map(str::to_owned); self.telegram_link_code_created_at = code.and(created_at); self.save(db).await } /// Complete Telegram linking and clear any pending code. pub async fn complete_telegram_link( &mut self, db: &Database, telegram_id: &str, ) -> cot::db::Result<()> { self.telegram_id = Some(telegram_id.to_owned()); self.telegram_link_code = None; self.telegram_link_code_created_at = None; self.save(db).await } /// Remove Telegram credentials and pending link data. pub async fn clear_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> { self.telegram_id = None; self.telegram_link_code = None; self.telegram_link_code_created_at = None; self.save(db).await } /// Store an explicit opt-out and clear pending link data. pub async fn decline_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> { self.telegram_id = Some(String::new()); self.telegram_link_code = None; self.telegram_link_code_created_at = None; self.save(db).await } /// Delete this user by primary key. pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { cot::db::query!(User, $id == Auto::Fixed(user_id)) .delete(db) .await?; Ok(()) } // Accessor helpers for templates pub fn id_val(&self) -> i64 { self.id.unwrap() } pub fn username_str(&self) -> &str { &self.username } pub fn email_str(&self) -> String { self.email.clone().unwrap_or_default() } pub fn display_name_str(&self) -> String { self.display_name.clone().unwrap_or_default() } pub fn telegram_id(&self) -> Option<&str> { self.telegram_id.as_deref() } pub fn telegram_link_code(&self) -> Option<&str> { self.telegram_link_code.as_deref() } pub fn telegram_link_code_created_at(&self) -> Option { self.telegram_link_code_created_at } pub fn role_str(&self) -> &str { &self.role } pub fn is_active(&self) -> bool { self.is_active } } // --------------------------------------------------------------------------- // OidcLink model // --------------------------------------------------------------------------- #[derive(Debug, Clone)] #[cot::db::model] pub struct OidcLink { #[model(primary_key)] id: Auto, user_id: i64, issuer: LimitedString<255>, sub: LimitedString<255>, email: Option, name: Option, avatar_url: Option, } // --------------------------------------------------------------------------- // OidcLink helper methods // --------------------------------------------------------------------------- impl OidcLink { /// Find an OIDC link by issuer + subject. pub async fn find_by_issuer_sub( db: &Database, issuer: &str, sub: &str, ) -> cot::db::Result> { let Ok(issuer) = LimitedString::<255>::new(issuer) else { return Ok(None); }; let Ok(sub) = LimitedString::<255>::new(sub) else { return Ok(None); }; cot::db::query!(OidcLink, $issuer == issuer && $sub == sub) .get(db) .await } /// Create a new OIDC link for a user. pub async fn create_link( db: &Database, user_id: i64, issuer: &str, sub: &str, email: Option<&str>, name: Option<&str>, ) -> cot::db::Result { let mut link = Self { id: Auto::auto(), user_id, issuer: LimitedString::new(issuer).unwrap(), sub: LimitedString::new(sub).unwrap(), email: email.map(str::to_owned), name: name.map(str::to_owned), avatar_url: None, }; link.insert(db).await?; Ok(link) } /// Update cached claims (email, name) on an existing link. pub async fn update_claims( &mut self, db: &Database, email: Option<&str>, name: Option<&str>, ) -> cot::db::Result<()> { self.email = email.map(str::to_owned); self.name = name.map(str::to_owned); self.save(db).await } /// Delete this OIDC link by primary key. pub async fn delete(self, db: &Database) -> cot::db::Result<()> { let link_id = self.id; cot::db::query!(OidcLink, $id == link_id).delete(db).await?; Ok(()) } /// Accessor for the linked user ID. pub fn user_id(&self) -> i64 { self.user_id } } // --------------------------------------------------------------------------- // Migrations // --------------------------------------------------------------------------- pub mod db_migrations { use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; use cot::db::{DatabaseField, Identifier, LimitedString}; // -- M0002: create amnezia_fellow__user -------------------------------- #[derive(Debug, Copy, Clone)] pub struct M0002CreateUser; impl migrations::Migration for M0002CreateUser { const APP_NAME: &'static str = "amnezia_fellow"; const MIGRATION_NAME: &'static str = "m_0002_create_user"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "amnezia_fellow", "m_0001_create_config_entry", )]; const OPERATIONS: &'static [Operation] = &[Operation::create_model() .table_name(Identifier::new("amnezia_fellow__user")) .fields(&[ Field::new(Identifier::new("id"), ::TYPE) .primary_key() .auto(), Field::new( Identifier::new("username"), as DatabaseField>::TYPE, ) .unique(), Field::new(Identifier::new("password"), ::TYPE) .set_null(true), Field::new(Identifier::new("email"), ::TYPE) .set_null(true), Field::new( Identifier::new("display_name"), ::TYPE, ) .set_null(true), Field::new( Identifier::new("avatar_url"), ::TYPE, ) .set_null(true), Field::new( Identifier::new("role"), as DatabaseField>::TYPE, ), Field::new(Identifier::new("is_active"), ::TYPE), ]) .build()]; } // -- M0003: create amnezia_fellow__oidc_link --------------------------- #[derive(Debug, Copy, Clone)] pub struct M0003CreateOidcLink; impl migrations::Migration for M0003CreateOidcLink { const APP_NAME: &'static str = "amnezia_fellow"; const MIGRATION_NAME: &'static str = "m_0003_create_oidc_link"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "amnezia_fellow", "m_0002_create_user", )]; const OPERATIONS: &'static [Operation] = &[Operation::create_model() .table_name(Identifier::new("amnezia_fellow__oidc_link")) .fields(&[ Field::new(Identifier::new("id"), ::TYPE) .primary_key() .auto(), Field::new(Identifier::new("user_id"), ::TYPE), Field::new( Identifier::new("issuer"), as DatabaseField>::TYPE, ), Field::new( Identifier::new("sub"), as DatabaseField>::TYPE, ), Field::new(Identifier::new("email"), ::TYPE) .set_null(true), Field::new(Identifier::new("name"), ::TYPE).set_null(true), Field::new( Identifier::new("avatar_url"), ::TYPE, ) .set_null(true), ]) .build()]; } // -- M0004: indexes on amnezia_fellow__oidc_link ----------------------- #[cot::db::migrations::migration_op] async fn create_oidc_link_indexes( ctx: migrations::MigrationContext<'_>, ) -> cot::db::Result<()> { ctx.db .raw( "CREATE UNIQUE INDEX idx_amnezia_fellow_oidc_link_issuer_sub \ ON amnezia_fellow__oidc_link (issuer, sub)", ) .await?; ctx.db .raw( "CREATE INDEX idx_amnezia_fellow_oidc_link_user_id \ ON amnezia_fellow__oidc_link (user_id)", ) .await?; Ok(()) } #[derive(Debug, Copy, Clone)] pub struct M0004OidcLinkIndexes; impl migrations::Migration for M0004OidcLinkIndexes { const APP_NAME: &'static str = "amnezia_fellow"; const MIGRATION_NAME: &'static str = "m_0004_oidc_link_indexes"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "amnezia_fellow", "m_0003_create_oidc_link", )]; const OPERATIONS: &'static [Operation] = &[Operation::custom(create_oidc_link_indexes).build()]; } // -- M0007: Telegram link state on amnezia_fellow__user ---------------- #[cot::db::migrations::migration_op] async fn add_user_telegram_id(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { ctx.db .raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_id TEXT") .await?; ctx.db .raw( "CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_id \ ON amnezia_fellow__user (telegram_id) \ WHERE telegram_id IS NOT NULL AND telegram_id != ''", ) .await?; Ok(()) } #[derive(Debug, Copy, Clone)] pub struct M0007UserTelegramId; impl migrations::Migration for M0007UserTelegramId { const APP_NAME: &'static str = "amnezia_fellow"; const MIGRATION_NAME: &'static str = "m_0007_user_telegram_id"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "amnezia_fellow", "m_0004_oidc_link_indexes", )]; const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()]; } // -- M0008: pending Telegram link code on amnezia_fellow__user --------- #[cot::db::migrations::migration_op] async fn add_user_telegram_link_code( ctx: migrations::MigrationContext<'_>, ) -> cot::db::Result<()> { ctx.db .raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_link_code TEXT") .await?; ctx.db .raw( "ALTER TABLE amnezia_fellow__user \ ADD COLUMN telegram_link_code_created_at INTEGER", ) .await?; ctx.db .raw( "CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_link_code \ ON amnezia_fellow__user (telegram_link_code) \ WHERE telegram_link_code IS NOT NULL AND telegram_link_code != ''", ) .await?; Ok(()) } #[derive(Debug, Copy, Clone)] pub struct M0008UserTelegramLinkCode; impl migrations::Migration for M0008UserTelegramLinkCode { const APP_NAME: &'static str = "amnezia_fellow"; const MIGRATION_NAME: &'static str = "m_0008_user_telegram_link_code"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "amnezia_fellow", "m_0007_user_telegram_id", )]; const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_link_code).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0002CreateUser, &M0003CreateOidcLink, &M0004OidcLinkIndexes, &M0007UserTelegramId, &M0008UserTelegramLinkCode, ]; }