476 lines
14 KiB
Rust
476 lines
14 KiB
Rust
|
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(())
|
||
|
}
|
||
|
}
|