Files
amnezia-fellow/src/user.rs
T
Ultradesu 77cde17ef9
Build and Publish / Build and Publish Docker Image (push) Successful in 6m0s
Added telegram bot WEB App
2026-07-01 12:44:53 +03:00

574 lines
19 KiB
Rust

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<i64>,
#[model(unique)]
username: LimitedString<255>,
password: Option<String>,
email: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
telegram_id: Option<String>,
telegram_link_code: Option<String>,
telegram_link_code_created_at: Option<i64>,
role: LimitedString<32>,
is_active: bool,
}
// ---------------------------------------------------------------------------
// User helper methods
// ---------------------------------------------------------------------------
impl User {
/// List all users.
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
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<Option<Self>> {
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<Self> {
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<Self> {
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<Option<Self>> {
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<Option<Self>> {
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<Option<Self>> {
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<Option<Self>> {
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<u64> {
Self::objects().count(db).await
}
/// Return a reference to the password hash, if set.
pub fn password_ref(&self) -> Option<PasswordHash> {
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<i64>,
) -> 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<i64> {
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<i64>,
user_id: i64,
issuer: LimitedString<255>,
sub: LimitedString<255>,
email: Option<String>,
name: Option<String>,
avatar_url: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<Option<Self>> {
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<Self> {
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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("username"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.unique(),
Field::new(Identifier::new("password"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("email"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(
Identifier::new("display_name"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("role"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("is_active"), <bool as DatabaseField>::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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("issuer"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("sub"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("email"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE).set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::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,
];
}