use super::{ crates::UserCratePermission, permissions::UserPermission, schema::{user_crate_permissions, user_sessions, user_ssh_keys, users}, uuid::SqlUuid, ConnectionPool, Error, Result, }; use chrono::{Duration, NaiveDateTime, Utc}; use diesel::{ dsl::sql, insert_into, prelude::*, result::{DatabaseErrorKind, Error as DieselError}, Associations, Identifiable, Queryable, }; use rand::{thread_rng, Rng}; use std::sync::Arc; use thrussh_keys::PublicKeyBase64; #[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)] pub struct User { pub id: i32, pub uuid: SqlUuid, pub username: String, pub password: Option, pub name: Option, pub nick: Option, pub email: Option, pub external_profile_url: Option, pub picture_url: Option, } impl User { pub async fn search( conn: ConnectionPool, given_query: String, limit: i64, ) -> Result> { use crate::schema::users::dsl::{name, nick, username}; tokio::task::spawn_blocking(move || { let conn = conn.get()?; let query = format!("%{}%", given_query); Ok(crate::schema::users::table .filter( username .like(&query) .or(name.like(&query)) .or(nick.like(&query)), ) .limit(limit) .load(&conn)?) }) .await? } pub async fn find_by_username( conn: ConnectionPool, given_username: String, ) -> Result> { use crate::schema::users::dsl::username; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(crate::schema::users::table .filter(username.eq(given_username)) .get_result(&conn) .optional()?) }) .await? } pub async fn find_by_uuid( conn: ConnectionPool, given_uuid: uuid::Uuid, ) -> Result> { use crate::schema::users::dsl::uuid; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(crate::schema::users::table .filter(uuid.eq(SqlUuid(given_uuid))) .get_result(&conn) .optional()?) }) .await? } pub async fn find_by_session_key( conn: ConnectionPool, given_session_key: String, ) -> Result> { use crate::schema::user_sessions::dsl::{expires_at, session_key}; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(user_sessions::table .filter( expires_at .is_null() .or(expires_at.gt(chrono::Utc::now().naive_utc())), ) .filter(session_key.eq(given_session_key)) .inner_join(users::table) .select((user_sessions::all_columns, users::all_columns)) .get_result(&conn) .optional()?) }) .await? } pub async fn find_by_ssh_key( conn: ConnectionPool, given_ssh_key: Vec, ) -> Result> { use crate::schema::user_ssh_keys::dsl::ssh_key; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(crate::schema::user_ssh_keys::table .filter(ssh_key.eq(given_ssh_key)) .inner_join(users::table) .select((user_ssh_keys::all_columns, users::all_columns)) .get_result(&conn) .optional()?) }) .await? } /// Lookup the user in the database by username, or create the user if it doesn't yet /// exist. The user will be created with no password so it cannot be logged into using /// standard `password` auth, and must be logged into using OAuth. pub async fn find_or_create( conn: ConnectionPool, given_username: String, given_name: Option, given_nick: Option, given_email: Option, given_external_profile_url: Option, given_picture_url: Option, ) -> Result { use crate::schema::users::dsl::{ email, external_profile_url, name, nick, picture_url, username, uuid, }; tokio::task::spawn_blocking(move || { let conn = conn.get()?; let user: Option = crate::schema::users::table .filter(username.eq(&given_username)) .get_result(&conn) .optional()?; if let Some(user) = user { return Ok(user); } diesel::insert_into(users::table) .values(( username.eq(&given_username), uuid.eq(SqlUuid::random()), name.eq(&given_name), nick.eq(&given_nick), email.eq(&given_email), external_profile_url.eq(given_external_profile_url.map(|v| v.to_string())), picture_url.eq(given_picture_url.map(|v| v.to_string())), )) .execute(&conn)?; Ok(crate::schema::users::table .filter(username.eq(given_username)) .get_result(&conn)?) }) .await? } pub async fn register( conn: ConnectionPool, username: String, password_hash: String, ) -> Result<()> { tokio::task::spawn_blocking(move || { let conn = conn.get()?; let res = diesel::insert_into(users::table) .values(( users::username.eq(&username), users::uuid.eq(SqlUuid::random()), users::password.eq(&password_hash), )) .execute(&conn); match res { Ok(_) => Ok(()), Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { Err(Error::UsernameTaken) } Err(e) => Err(e.into()), } }) .await? } /// Parses an ssh key from its `ssh-add -L` format (`ssh-ed25519 AAAAC3N...`) and /// inserts it to the database for the user. pub async fn insert_ssh_key( self: Arc, conn: ConnectionPool, ssh_key: &str, ) -> Result<()> { let mut split = ssh_key.split_whitespace(); let key = match (split.next(), split.next()) { (Some(_), Some(key)) | (Some(key), None) => key, _ => return Err(thrussh_keys::Error::CouldNotReadKey.into()), }; let parsed_key = thrussh_keys::parse_public_key_base64(key)?; let parsed_name = split.next().unwrap_or("(none)").to_string(); tokio::task::spawn_blocking(move || { use crate::schema::user_ssh_keys::dsl::{name, ssh_key, user_id, uuid}; let conn = conn.get()?; insert_into(crate::schema::user_ssh_keys::dsl::user_ssh_keys) .values(( uuid.eq(SqlUuid::random()), name.eq(parsed_name), ssh_key.eq(parsed_key.public_key_bytes()), user_id.eq(self.id), )) .execute(&conn)?; Ok(()) }) .await? } pub async fn delete_user_ssh_key_by_uuid( self: Arc, conn: ConnectionPool, ssh_key_id: uuid::Uuid, ) -> Result { use crate::schema::user_ssh_keys::dsl::{user_id, user_ssh_keys, uuid}; tokio::task::spawn_blocking(move || { let conn = conn.get()?; let rows = diesel::delete( user_ssh_keys .filter(user_id.eq(self.id)) .filter(uuid.eq(SqlUuid(ssh_key_id))), ) .execute(&conn)?; Ok(rows > 0) }) .await? } /// Get all the SSH keys for the user. pub async fn list_ssh_keys(self: Arc, conn: ConnectionPool) -> Result> { tokio::task::spawn_blocking(move || { use crate::schema::user_ssh_keys::dsl::user_id; let conn = conn.get()?; let selected = crate::schema::user_ssh_keys::table .filter(user_id.eq(self.id)) .inner_join(users::table) .select(user_ssh_keys::all_columns) .load(&conn)?; Ok(selected) }) .await? } pub async fn accessible_crates( self: Arc, conn: ConnectionPool, ) -> Result> { use crate::schema::crates; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(UserCratePermission::belonging_to(&*self) .inner_join(crate::schema::crates::table) .select((user_crate_permissions::permissions, crates::all_columns)) .load(&conn)?) }) .await? } pub async fn get_crate_permissions( self: Arc, conn: ConnectionPool, crate_id: i32, ) -> Result { Ok(UserCratePermission::find(conn, self.id, crate_id) .await? .unwrap_or_default() .permissions) } #[must_use] pub fn display_name(&self) -> &str { self.nick .as_ref() .or(self.name.as_ref()) .unwrap_or(&self.username) } pub async fn published_versions_by_date( &self, conn: ConnectionPool, ) -> Result> { let id = self.id; tokio::task::spawn_blocking(move || { use crate::schema::crate_versions::dsl::{created_at, user_id}; let conn = conn.get()?; let date_statement = if cfg!(feature = "sqlite") { "strftime('%Y-%m-%d 00:00:00', created_at)" } else { "DATE_TRUNC('day', created_at)" }; let selected = crate::schema::crate_versions::table .group_by(sql::(date_statement)) .select(( sql::(date_statement), sql::("count(*)"), )) .filter( user_id .eq(id) .and(created_at.gt((Utc::now() - Duration::days(365)).naive_utc())), ) .load(&conn)?; Ok(selected) }) .await? } } #[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)] #[belongs_to(User)] #[belongs_to(UserSshKey)] pub struct UserSession { pub id: i32, pub user_id: i32, pub session_key: String, pub user_ssh_key_id: Option, pub expires_at: Option, pub user_agent: Option, pub ip: Option, pub uuid: SqlUuid, } impl UserSession { pub async fn generate( conn: ConnectionPool, given_user_id: i32, given_user_ssh_key_id: Option, given_expires_at: Option, given_user_agent: Option, given_ip: Option, ) -> Result { use crate::schema::user_sessions::dsl::{ expires_at, ip, session_key, user_agent, user_id, user_sessions, user_ssh_key_id, uuid, }; tokio::task::spawn_blocking(move || { let conn = conn.get()?; let generated_session_key: String = thread_rng() .sample_iter(&rand::distributions::Alphanumeric) .take(48) .map(char::from) .collect(); insert_into(user_sessions) .values(( user_id.eq(given_user_id), session_key.eq(&generated_session_key), user_ssh_key_id.eq(given_user_ssh_key_id), expires_at.eq(given_expires_at), user_agent.eq(given_user_agent), ip.eq(given_ip), uuid.eq(SqlUuid::random()), )) .execute(&conn)?; Ok(crate::schema::user_sessions::table .filter(session_key.eq(generated_session_key)) .get_result(&conn)?) }) .await? } pub async fn list( conn: ConnectionPool, given_user_id: i32, ) -> Result)>> { use crate::schema::user_sessions::dsl::{expires_at, user_id}; tokio::task::spawn_blocking(move || { let conn = conn.get()?; Ok(crate::schema::user_sessions::table .filter( expires_at .is_null() .or(expires_at.gt(chrono::Utc::now().naive_utc())), ) .filter(user_id.eq(given_user_id)) .left_join(user_ssh_keys::table) .load(&conn)?) }) .await? } pub async fn delete(self: Arc, conn: ConnectionPool) -> Result { tokio::task::spawn_blocking(move || { let conn = conn.get()?; let res = diesel::delete(user_sessions::table) .filter(user_sessions::id.eq(self.id)) .execute(&conn)?; Ok(res > 0) }) .await? } pub async fn delete_by_uuid(conn: ConnectionPool, uuid: uuid::Uuid) -> Result { tokio::task::spawn_blocking(move || { let conn = conn.get()?; let res = diesel::delete(user_sessions::table) .filter(user_sessions::uuid.eq(SqlUuid(uuid))) .execute(&conn)?; Ok(res > 0) }) .await? } pub async fn extend( self: Arc, conn: ConnectionPool, given_expires_at: chrono::NaiveDateTime, ) -> Result { use crate::schema::user_sessions::dsl::expires_at; tokio::task::spawn_blocking(move || { let conn = conn.get()?; let res = diesel::update(user_sessions::table) .filter(user_sessions::id.eq(self.id)) .set(expires_at.eq(given_expires_at)) .execute(&conn)?; Ok(res > 0) }) .await? } } #[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)] #[belongs_to(User)] pub struct UserSshKey { pub id: i32, pub uuid: SqlUuid, pub name: String, pub user_id: i32, pub ssh_key: Vec, pub created_at: chrono::NaiveDateTime, pub last_used_at: Option, } impl UserSshKey { /// Every SSH key should have a corresponding session so when the config is pulled from git we /// can return a key in there. The session might have, however, been compromised and removed /// using the Web UI/database/etc - this function will regenerate the key on next pull so /// there's no disruption in service. pub async fn get_or_insert_session( self: Arc, conn: ConnectionPool, ip: Option, ) -> Result { use crate::schema::user_sessions::dsl::{expires_at, user_id}; let res: Option = tokio::task::spawn_blocking({ let conn = conn.clone(); let this = self.clone(); move || { let conn = conn.get()?; UserSession::belonging_to(&*this) .filter( expires_at .is_null() .or(expires_at.gt(chrono::Utc::now().naive_utc())), ) .filter(user_id.eq(this.user_id)) .get_result(&conn) .optional() .map_err(crate::Error::Query) } }) .await??; if let Some(res) = res { Ok(res) } else { UserSession::generate(conn, self.user_id, Some(self.id), None, None, ip).await } } /// Updates the last used time of this SSH key for reporting purposes in the /// dashboard. pub async fn update_last_used(self: Arc, conn: ConnectionPool) -> Result<()> { use crate::schema::user_ssh_keys::dsl::{id, last_used_at, user_ssh_keys}; tokio::task::spawn_blocking(move || { let conn = conn.get()?; diesel::update(user_ssh_keys.filter(id.eq(self.id))) .set(last_used_at.eq(diesel::dsl::now)) .execute(&conn) .map(|_| ()) .map_err(Into::into) }) .await? } /// Retrieves the key fingerprint, encoded in hex and separated in two character chunks /// with colons. pub fn fingerprint(&self) -> Result { let key = thrussh_keys::key::parse_public_key(&self.ssh_key)?; let raw_hex = hex::encode( base64::decode(&key.fingerprint()).map_err(|_| thrussh_keys::Error::CouldNotReadKey)?, ); let mut hex = String::with_capacity(raw_hex.len() + (raw_hex.len() / 2 - 1)); for (i, c) in raw_hex.chars().enumerate() { if i != 0 && i % 2 == 0 { hex.push(':'); } hex.push(c); } Ok(hex) } }