evie/blogdb/src/users.rs

476 lines
14 KiB
Rust
Raw Normal View History

2024-12-14 15:37:35 -05:00
use std::fmt::Display;
use anyhow::{anyhow, Context};
use rand::{RngCore, SeedableRng};
use rand_chacha::ChaCha20Rng;
use sqlx::{prelude::FromRow, SqlitePool};
use crate::{assets::Asset, hash_value, verify_hashed_value, BlogDb};
#[derive(Debug, PartialEq)]
pub struct UserInfo {
pub name: String,
pub about: String,
pub home: String,
}
impl From<User> for UserInfo {
fn from(value: User) -> Self {
Self {
name: value.name,
about: value.about,
home: value.home,
}
}
}
#[derive(Clone, FromRow, Default)]
struct User {
name: String,
password_hash: String,
about: String, // this isn't great but these r the home and about page html
home: String,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"User(name: {}, password_hash: ###, homepage: {:?})",
self.name, self.home
)
}
}
impl BlogDb {
pub(super) async fn verify_password<S>(&self, user: S, password: String) -> anyhow::Result<()>
where
S: AsRef<str>,
{
let user: User = sqlx::query_as("SELECT * FROM users WHERE name=?")
.bind(user.as_ref())
.fetch_one(&self.db)
.await?;
let hash = user.password_hash;
verify_hashed_value(password, hash).await
}
pub(super) async fn add_initial_user<S>(
db: &SqlitePool,
username: S,
password: String,
) -> anyhow::Result<()>
where
S: AsRef<str>,
{
let password_hash = hash_value(password).await?;
sqlx::query("INSERT OR IGNORE INTO users (name, password_hash) VALUES (?,?)")
.bind(username.as_ref())
.bind(password_hash)
.execute(db)
.await
.with_context(|| "Something went wrong while executing sqlite query")?;
Ok(())
}
/// Update password with new string, authenticated with current valid password hash
pub async fn update_password<S>(
&self,
user: S,
password: String,
session_id: i64,
) -> anyhow::Result<()>
where
S: AsRef<str>,
{
if user.as_ref() != &self.check_session(session_id).await? {
return Err(anyhow::Error::msg(
"Updating password, username doesn't match session username",
));
};
let hash = hash_value(password.clone()).await?;
sqlx::query("UPDATE users SET (password_hash) = ?")
.bind(&hash)
.execute(&self.db)
.await?;
sqlx::query("UPDATE sessions SET (password_hash) = ? WHERE user = ?")
.bind(&hash)
.bind(user.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
pub async fn get_user(&self) -> UserInfo {
let user: UserInfo = sqlx::query_as::<_, User>("SELECT * FROM users")
.fetch_one(&self.db)
.await
.unwrap_or_default()
.into();
user
}
pub async fn get_user_info<S>(&self, user: S) -> anyhow::Result<UserInfo>
where
S: AsRef<str>,
{
let user: User = sqlx::query_as("SELECT * FROM users WHERE name=?")
.bind(user.as_ref())
.fetch_one(&self.db)
.await?;
Ok(user.into())
}
pub async fn update_user_name<S, T>(
&self,
user: S,
name: T,
session_id: i64,
) -> anyhow::Result<UserInfo>
where
S: AsRef<str>,
T: AsRef<str>,
{
self.authorize_user(user.as_ref(), session_id)
.await
.map_err(|_| {
anyhow!(
"An unauthorized user tried to update user {}'s name",
user.as_ref()
)
})?;
let info: UserInfo =
sqlx::query_as::<_, User>("UPDATE users SET (name) = ? WHERE name=? RETURNING *")
.bind(name.as_ref())
.bind(user.as_ref())
.fetch_one(&self.db)
.await?
.into();
sqlx::query("UPDATE sessions SET (user) = ? WHERE user=?")
.bind(&info.name)
.bind(user.as_ref())
.execute(&self.memory)
.await?;
Ok(info)
}
pub async fn get_homepage(&self) -> String {
let user: User = sqlx::query_as("SELECT * FROM users")
.fetch_one(&self.db)
.await
.unwrap_or_default();
user.home
}
pub async fn get_about(&self) -> String {
let user: User = sqlx::query_as("SELECT * FROM users")
.fetch_one(&self.db)
.await
.unwrap_or_default();
user.about
}
pub async fn update_user_homepage<S, T>(
&self,
user: S,
home: T,
session_id: i64,
) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
self.authorize_user(user.as_ref(), session_id)
.await
.map_err(|_| {
anyhow!(
"An unauthorized user tried to update user {}'s name",
user.as_ref()
)
})?;
sqlx::query("UPDATE users SET (home) = ? WHERE name=? RETURNING *")
.bind(home.as_ref())
.bind(user.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
pub async fn update_user_about<S, T>(
&self,
user: S,
about: T,
session_id: i64,
) -> anyhow::Result<UserInfo>
where
S: AsRef<str>,
T: AsRef<str>,
{
self.authorize_user(user.as_ref(), session_id)
.await
.map_err(|_| {
anyhow!(
"An unauthorized user tried to update user {}'s bio",
user.as_ref()
)
})?;
let info =
sqlx::query_as::<_, User>("UPDATE users SET (about) = ? WHERE name=? RETURNING *")
.bind(about.as_ref())
.bind(user.as_ref())
.fetch_one(&self.db)
.await?
.into();
Ok(info)
}
// /// Updates user profile pic with asset from database
// pub async fn update_user_profile_pic<S, T>(
// &self,
// user: S,
// profile_pic_name: T,
// session_id: i64,
// ) -> anyhow::Result<UserInfo>
// where
// S: AsRef<str>,
// T: AsRef<str>,
// {
// self.authorize_user(user.as_ref(), session_id)
// .await
// .map_err(|_| {
// anyhow!(
// "An unauthorized user tried to update user {}'s profile pic",
// user.as_ref()
// )
// })?;
// let info = sqlx::query_as::<_, User>(
// "UPDATE users SET (profile_pic) = ? WHERE name=? RETURNING *",
// )
// .bind(profile_pic_name.as_ref())
// .bind(user.as_ref())
// .fetch_one(&self.db)
// .await?
// .into();
// Ok(info)
// }
// /// Replaces user profile pic with new photo, saving it to the database as an asset
// pub async fn replace_user_profile_pic<S, D>(
// &self,
// user: S,
// profile_pic_data: D,
// session_id: i64,
// ) -> anyhow::Result<(Asset, UserInfo)>
// where
// S: AsRef<str>,
// D: Into<Vec<u8>>,
// {
// self.authorize_user(user.as_ref(), session_id)
// .await
// .map_err(|_| {
// anyhow!(
// "An unauthorized user tried to replace user {}'s profile pic",
// user.as_ref()
// )
// })?;
// let data = profile_pic_data.into();
// let user_name = self.get_user_info(user.as_ref()).await?.name;
// let ext = infer::get(&data)
// .ok_or(anyhow!("Couldn't get the profile pic filetype"))?
// .extension();
// let name = format!("{user_name}-profile.{ext}");
// let profile_pic = self.add_asset(name, data).await?;
// let updated_info: UserInfo = sqlx::query_as::<_, User>(
// "UPDATE users SET (profile_pic) = ? WHERE name=? RETURNING *",
// )
// .bind(&profile_pic.slug)
// .bind(user.as_ref())
// .fetch_one(&self.db)
// .await?
// .into();
// Ok((profile_pic, updated_info))
// }
/// Check if a given session id is valid, and if it is, get the user it's valid for
pub async fn check_session<I>(&self, session_id: I) -> anyhow::Result<String>
where
I: Into<i64>,
{
let _ = sqlx::query("DELETE * FROM SESSIONS WHERE date<DATE('now')")
.execute(&self.memory)
.await;
let (_, user_id): (i64, String) = sqlx::query_as("SELECT * FROM sessions WHERE id=?")
.bind(session_id.into())
.fetch_one(&self.memory)
.await?;
Ok(user_id)
}
/// Log the user in, returning a session id.
pub async fn new_session<S, T>(
&self,
user: S,
password: String,
expires: T,
) -> anyhow::Result<i64>
where
S: AsRef<str>,
T: AsRef<str>,
{
self.verify_password(user.as_ref(), password).await?;
// ChaCha20Rng panics if it can't get secure entropy, which prolly won't happen but still
// want to catch if it does
let id = std::panic::catch_unwind(|| ChaCha20Rng::from_entropy().next_u64())
.map_err(|_| anyhow!("Couldn't get secure entropy to generate session id"))?
as i64;
sqlx::query("INSERT INTO sessions VALUES (?,?, DATE('now', ?))")
.bind(id)
.bind(user.as_ref())
.bind(expires.as_ref())
.execute(&self.memory)
.await?;
Ok(id)
}
// /// Get a session for the server, returning a session id.
// pub async fn get_server_session(&self) -> anyhow::Result<i64> {
// // ChaCha20Rng panics if it can't get secure entropy, which prolly won't happen but still
// // want to catch if it does
// let id = std::panic::catch_unwind(|| ChaCha20Rng::from_entropy().next_u64())
// .map_err(|_| anyhow!("Couldn't get secure entropy to generate session id"))?
// as i64;
// let user: String = random::<u64>().to_string();
// sqlx::query("INSERT INTO sessions VALUES (?,?)")
// .bind(id)
// .bind(user)
// .execute(&self.memory)
// .await?;
// Ok(id)
// }
/// End the user session, call this when logging out the user
pub async fn end_session<I>(&self, id: I) -> anyhow::Result<()>
where
I: Into<i64>,
{
sqlx::query("DELETE FROM sessions WHERE id=?")
.bind(id.into())
.execute(&self.memory)
.await?;
Ok(())
}
/// Check if a session is valid for a given user
pub(super) async fn authorize_user<S, I>(&self, user: S, session_id: I) -> anyhow::Result<()>
where
S: AsRef<str>,
I: Into<i64> + Copy,
{
let session_user = self.check_session(session_id).await?;
// this could be a match or if/else statement but i think it's a funny one-liner
(user.as_ref() == session_user).then_some(()).ok_or(anyhow!(
"Session {} is not valid for user {}",
session_id.into(),
user.as_ref()
))
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::util::tests::*;
#[sqlx::test]
async fn user_info(pool: SqlitePool) -> anyhow::Result<()> {
let (db, session_id) = get_init_db(pool).await?;
// Getting info
let expected_info: UserInfo = UserInfo {
name: "evie".to_string(),
about: "".to_string(),
home: None,
};
assert_eq!(db.get_user_info("evie").await?, expected_info);
// Updating info
let user = db.get_user_info("evie").await?;
let new_name = "august";
let new_bio = "loves george";
db.update_user_name(&user.name, new_name, session_id)
.await?;
db.update_user_about(new_name, new_bio, session_id).await?;
// PNG magic numbers
let profile_pic_data = &[0x89, 0x50, 0x4e, 0x47];
let profile_pic = db.add_asset("profile.png", profile_pic_data).await?;
let updated_info = db
.update_user_profile_pic(new_name, &profile_pic.slug, session_id)
.await?;
assert_eq!(updated_info.profile_pic, Some(profile_pic.slug));
db.replace_user_profile_pic(new_name, profile_pic_data, session_id)
.await?;
Ok(())
}
#[sqlx::test]
async fn sessions(pool: SqlitePool) -> anyhow::Result<()> {
let (db, _) = get_init_db(pool).await?;
let username = "evie";
let password = "hunter2".to_string();
let id = db.new_session(username, password, "+5 minutes").await?;
let session_result = db.check_session(id).await;
assert!(session_result.is_ok());
let logout = db.end_session(id).await;
assert!(logout.is_ok());
Ok(())
}
#[sqlx::test]
async fn update_password(pool: SqlitePool) -> anyhow::Result<()> {
let (db, session_id) = get_init_db(pool).await?;
let update = db
.update_password(
"evie",
"hunter2".to_string(),
"password".to_string(),
session_id,
)
.await;
assert!(update.is_ok());
Ok(())
}
}