Initial commit

This commit is contained in:
august kline 2024-11-13 21:38:41 -05:00
commit d7798783ba
12 changed files with 3038 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
.DS_Store

1703
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "blogdb"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.89"
argon2 = { version = "0.5.3", features = ["std"] }
infer = "0.16.0"
mime_guess = "2.0.5"
rand = "0.8.5"
rand_chacha = { version = "0.3.1", features = ["std"] }
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "macros"] }
tokio = { version = "1.40.0", features = ["macros", "rt", "rt-multi-thread"] }

View File

@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS users (
name VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
about TEXT,
home TEXT
);
CREATE TABLE IF NOT EXISTS assets (
slug TEXT UNIQUE NOT NULL,
mime TEXT NOT NULL,
data BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY DEFAULT (lower(hex (randomblob (16)))),
title VARCHAR(255) NOT NULL,
date TEXT,
tags TEXT,
content TEXT
);
CREATE TABLE IF NOT EXISTS drafts (
id TEXT PRIMARY KEY DEFAULT (lower(hex (randomblob (16)))),
title VARCHAR(255) NOT NULL,
tags TEXT,
content TEXT
);

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS pages (
slug STRING PRIMARY KEY UNIQUE NOT NULL,
content STRING NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY NOT NULL,
user STRING NOT NULL,
expires TEXT
);

201
src/assets.rs Normal file
View File

@ -0,0 +1,201 @@
use std::{fmt::Debug, path::PathBuf, str::FromStr};
use anyhow::anyhow;
use infer::Type;
use mime_guess::Mime;
use rand::random;
use sqlx::prelude::FromRow;
use crate::BlogDb;
#[derive(Clone, FromRow)]
pub struct Asset {
pub slug: String,
pub mime: String,
pub data: Vec<u8>,
}
impl Debug for Asset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Asset(slug: {}, mime: {})", self.slug, self.mime)?;
Ok(())
}
}
const SUPPORTED_MIME_TYPES: &[&str] = &["image/jpeg", "font/woff", "font/woff2"];
impl BlogDb {
/// Add an asset with the given name and data.
///
/// If the asset shares a name with another asset in the parent path, the name will be prefixed with a unique id. Use the returned [`Asset`] name to access this asset in the future.
pub async fn add_asset<S, D>(&self, slug: S, data: D) -> anyhow::Result<Asset>
where
S: AsRef<str>,
D: Into<Vec<u8>>,
{
let data = data.into();
let slug = PathBuf::from(slug.as_ref());
let asset_ext: &str;
let mime = match slug.extension() {
Some(_) => {
asset_ext = "";
match mime_guess::from_path(&slug).first() {
Some(mime) => &mime.to_string(),
None => match infer::get(&data) {
Some(mime) => mime.mime_type(),
None => {
return Err(anyhow::Error::msg(
"Couldn't get the mime type of the provided asset",
))
}
},
}
}
None => match infer::get(&data) {
Some(mime) => {
asset_ext = mime.extension();
mime.mime_type()
}
None => {
return Err(anyhow::Error::msg(
"Couldn't get the mime type of the provided asset",
))
}
},
};
let mut asset_name = slug
.parent()
.map(|parent| parent.to_str().unwrap())
.unwrap_or("")
.to_string();
asset_name.push_str(&random::<i64>().to_string());
if asset_ext.is_empty() {
asset_name.push_str(&format!("-{}", slug.file_name().unwrap().to_str().unwrap()));
} else {
asset_name.push_str(&format!(
"-{}.{asset_ext}",
slug.file_stem().unwrap().to_str().unwrap()
));
}
let asset: Asset =
sqlx::query_as("INSERT INTO assets (slug, mime, data) VALUES (?,?,?) RETURNING *")
.bind(&asset_name)
.bind(&mime)
.bind(&data)
.fetch_one(&self.db)
.await?;
Ok(asset)
}
async fn asset_path_collides<S>(&self, slug: S) -> bool
where
S: AsRef<str>,
{
self.get_asset(slug).await.is_ok()
}
async fn mime_is_supported(&self, r#type: &Type) -> bool {
SUPPORTED_MIME_TYPES.contains(&r#type.mime_type())
}
/// Adds a collection of asset tuples to the db
pub async fn add_assets<A, S, D>(&self, assets: A) -> anyhow::Result<()>
where
A: IntoIterator<Item = (S, D)>,
S: AsRef<str>,
D: Into<Vec<u8>>,
{
let assets = assets.into_iter();
for asset in assets.into_iter() {
let slug = asset.0;
let data = asset.1.into();
let path = PathBuf::from(slug.as_ref());
let mime = match path.extension() {
Some(ext) => match ext.to_str() {
Some(str) => match mime_guess::from_ext(str).first() {
Some(mime) => mime.to_string(),
None => match infer::get(&data) {
Some(ext) => ext.mime_type().to_string(),
None => "text/plain".to_string(),
},
},
None => match infer::get(&data) {
Some(ext) => ext.mime_type().to_string(),
None => "text/plain".to_string(),
},
},
None => match infer::get(&data) {
Some(ext) => ext.mime_type().to_string(),
None => "text/plain".to_string(),
},
};
let result =
sqlx::query("REPLACE INTO assets (slug, mime, data) VALUES (?,?,?) RETURNING *")
.bind(slug.as_ref())
.bind(mime)
.bind(data)
.execute(&self.db)
.await;
}
Ok(())
}
/// Get asset by slug
pub async fn get_asset<S>(&self, slug: S) -> anyhow::Result<Asset>
where
S: AsRef<str>,
{
let asset = sqlx::query_as("SELECT * FROM assets WHERE slug=?")
.bind(slug.as_ref())
.fetch_one(&self.db)
.await;
// let asset: Asset = sqlx::query_as("SELECT * FROM assets WHERE slug=?")
// .bind(slug.as_ref())
// .fetch_one(&self.db)
// .await?;
Ok(asset?)
}
/// Get all assets
pub async fn get_assets(&self) -> anyhow::Result<Vec<Asset>> {
let assets: Vec<Asset> = sqlx::query_as("SELECT * FROM assets")
.fetch_all(&self.db)
.await?;
Ok(assets)
}
/// Delete asset by name
pub async fn delete_asset<S>(&self, name: S) -> anyhow::Result<()>
where
S: AsRef<str>,
{
sqlx::query("DELETE FROM assets WHERE name=?")
.bind(name.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::util::tests::*;
use sqlx::SqlitePool;
#[sqlx::test]
async fn assets(pool: SqlitePool) -> anyhow::Result<()> {
let (db, session_id) = get_init_db(pool).await?;
let slug = "picture";
// PNG magic numbers
let data = &[0x89, 0x50, 0x4e, 0x47];
let asset = db.add_asset(slug, data).await;
assert!(asset.is_ok());
Ok(())
}
}

216
src/drafts.rs Normal file
View File

@ -0,0 +1,216 @@
use sqlx::prelude::FromRow;
use crate::{posts::Post, BlogDb};
#[derive(Clone, FromRow, Debug)]
pub struct Draft {
pub id: String,
pub title: String,
// Valid markdown
pub content: String,
// Separated by commas, no whitespace
pub tags: String,
}
impl BlogDb {
/// Create a draft from an existing post, returning a [Draft] either for the new draft or for an existing draft
pub async fn edit_post<S>(&self, id: S) -> anyhow::Result<Draft>
where
S: AsRef<str>,
{
let draft_exists = sqlx::query_as::<_, Draft>("SELECT * FROM drafts WHERE id=?")
.bind(id.as_ref())
.fetch_one(&self.db)
.await;
if let Ok(draft) = draft_exists {
Ok(draft)
} else {
let post_exists = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE id=?")
.bind(id.as_ref())
.fetch_one(&self.db)
.await;
if let Ok(post) = post_exists {
let new_draft: Draft = sqlx::query_as("INSERT INTO drafts (id, title, tags, content) SELECT id, title, tags, content FROM posts WHERE id=? RETURNING *")
.bind(post.id).fetch_one(&self.db).await?;
Ok(new_draft)
} else {
Err(anyhow::Error::msg(
"Tried to edit a post that doesn't exist",
))
}
}
}
/// Get draft by id
pub async fn get_draft<S>(&self, id: S) -> anyhow::Result<Draft>
where
S: AsRef<str>,
{
let draft: Draft = sqlx::query_as("SELECT * FROM drafts WHERE id=?")
.bind(id.as_ref())
.fetch_one(&self.db)
.await?;
Ok(draft)
}
/// Delete draft by id
pub async fn delete_draft<S>(&self, id: S) -> anyhow::Result<()>
where
S: AsRef<str>,
{
sqlx::query_as::<_, Draft>("DELETE FROM drafts WHERE id=? RETURNING *")
.bind(id.as_ref())
.fetch_one(&self.db)
.await?;
Ok(())
}
/// Get all drafts
pub async fn get_drafts(&self, session_id: i64) -> anyhow::Result<Vec<Draft>> {
self.check_session(session_id).await?;
let drafts: Vec<Draft> = sqlx::query_as("SELECT * FROM drafts")
.fetch_all(&self.db)
.await?;
Ok(drafts)
}
/// Create a new draft with the given data. See [`edit_post`](Self::edit_post) to create a draft from an existing post
pub async fn add_draft<S, T, R>(&self, title: S, tags: T, content: R) -> anyhow::Result<Draft>
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
{
let draft: Draft =
sqlx::query_as("REPLACE INTO drafts (title, tags, content) VALUES (?,?,?) RETURNING *")
.bind(title.as_ref())
.bind(tags.as_ref())
.bind(content.as_ref())
.fetch_one(&self.db)
.await?;
Ok(draft)
}
pub async fn update_draft_title<S, T>(&self, id: S, title: T) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
sqlx::query("UPDATE drafts SET (title) = ? WHERE id=?")
.bind(title.as_ref())
.bind(id.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
pub async fn update_draft_tags<S, T>(&self, id: S, tags: T) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
sqlx::query("UPDATE drafts SET (tags) = ? WHERE id=?")
.bind(tags.as_ref())
.bind(id.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
pub async fn update_draft_content<S, T>(&self, id: S, content: T) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
sqlx::query("UPDATE drafts SET (content) = ? WHERE id=?")
.bind(content.as_ref())
.bind(id.as_ref())
.execute(&self.db)
.await?;
Ok(())
}
/// Publish draft by id, creating a new post if one with the id doesn't already exist, and updating the post if it does.
///
/// Note that if a new post is created its id may be incremented, making the id passed into this function invalid.
/// Always use the returned [`Post`] id for future actions.
pub async fn publish_draft<S>(&self, id: S) -> anyhow::Result<Post>
where
S: AsRef<str>,
{
let update = sqlx::query_as::<_, Post>(
"UPDATE posts
SET (title, tags, content) = (SELECT title, tags, content FROM drafts WHERE id=?)
WHERE id=?
RETURNING *",
)
.bind(id.as_ref())
.fetch_one(&self.db)
.await;
if let Ok(post) = update {
self.delete_draft(id).await?;
Ok(post)
} else {
let new_post: Post = sqlx::query_as(
"INSERT INTO posts (title, date, tags, content)
SELECT title, DATE('now', 'localtime'), tags, content FROM drafts WHERE id=?
RETURNING *",
)
.bind(id.as_ref())
.fetch_one(&self.db)
.await?;
self.delete_draft(id).await?;
Ok(new_post)
}
}
}
#[cfg(test)]
mod tests {
use crate::util::tests::*;
use sqlx::SqlitePool;
#[sqlx::test]
async fn drafts(pool: SqlitePool) -> anyhow::Result<()> {
let (db, session_id, post) = init_and_add_post(pool).await?;
// Test draft that updates existing post
let id = post.id;
db.edit_post(id).await?;
let title = "this is an updated title";
let content = "this is some updated content";
let tags = "updated";
db.update_draft_title(id, title).await?;
db.update_draft_content(id, content).await?;
db.update_draft_tags(id, tags).await?;
db.publish_draft(id).await?;
assert!(db.get_draft(id).await.is_err()); // Published drafts should be deleted
let post = db.get_post(id).await?;
assert_eq!(post.title, title.to_string());
assert_eq!(post.content, content.to_string());
assert_eq!(post.tags, tags.to_string());
// Test new draft
let title = "this is a new title";
let content = "this is new content";
let tags = "new";
let draft = db.add_draft(title, tags, content).await?;
assert!(db.get_draft(draft.id).await.is_ok());
let post = db.publish_draft(draft.id).await?;
assert!(db.get_post(post.id).await.is_ok());
assert_eq!(post.title, title.to_string());
assert_eq!(post.content, content.to_string());
assert_eq!(post.tags, tags.to_string());
Ok(())
}
}

83
src/lib.rs Normal file
View File

@ -0,0 +1,83 @@
mod assets;
mod drafts;
mod pages;
pub mod posts;
pub mod users;
mod util;
use posts::Post;
use util::*;
use std::fs::File;
use std::str::FromStr;
use sqlx::sqlite::SqliteJournalMode;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
SqlitePool,
};
#[cfg(not(debug_assertions))]
const MEMORY_URL: &str = "sqlite://:memory:";
#[cfg(debug_assertions)]
const MEMORY_URL: &str = "sqlite://cache.db";
/// A Sqlite database for all blog data.
///
/// It uses a file-backed db for posts, drafts, users, and assets, and an in-memory db for cached
/// web pages and session management.
#[derive(Clone)]
pub struct BlogDb {
db: SqlitePool,
memory: SqlitePool,
}
impl BlogDb {
/// Create a new BlogDb with an initial user.
pub async fn new<S, T>(username: S, password: String, db_url: T) -> anyhow::Result<Self>
where
S: AsRef<str>,
T: AsRef<str>,
{
let db_name = db_url.as_ref().strip_prefix("sqlite://").unwrap();
if File::open(db_name).is_err() {
let _ = File::create_new(db_name);
}
let db = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::from_str(db_url.as_ref())?
.journal_mode(SqliteJournalMode::Wal),
)
.await?;
#[cfg(debug_assertions)]
{
let db_name = MEMORY_URL.strip_prefix("sqlite://").unwrap();
if File::open(db_name).is_err() {
let _ = File::create_new(db_name);
}
}
let memory = SqlitePoolOptions::new()
.connect_with(
#[cfg(not(debug_assertions))]
SqliteConnectOptions::from_str(MEMORY_URL)?.journal_mode(SqliteJournalMode::Memory),
#[cfg(debug_assertions)]
SqliteConnectOptions::from_str(MEMORY_URL.as_ref())?
.journal_mode(SqliteJournalMode::Wal),
)
.await?;
Self::run_main_migrations(&db).await?;
Self::run_memory_migrations(&memory).await?;
Self::add_initial_user(&db, username.as_ref(), password).await?;
Ok(Self { db, memory })
}
async fn run_main_migrations(db: &SqlitePool) -> anyhow::Result<()> {
sqlx::migrate!("db/main/migrations").run(db).await?;
Ok(())
}
async fn run_memory_migrations(cache: &SqlitePool) -> anyhow::Result<()> {
sqlx::migrate!("db/memory/migrations").run(cache).await?;
Ok(())
}
}

155
src/pages.rs Normal file
View File

@ -0,0 +1,155 @@
use std::path::StripPrefixError;
use crate::BlogDb;
impl BlogDb {
// pub async fn cache_post<C>(&self, id: i64, cacher: C) -> anyhow::Result<()>
// where
// C: Cacher<Post> + Send + 'static,
// {
// let post = self.get_post(id).await?;
// let cached_page =
// task::spawn_blocking(move || -> anyhow::Result<String> { cacher.cache(post) })
// .await??; // await?? me?? me await??
// let slug = format!("blog/{id}");
// sqlx::query("REPLACE INTO pages (slug, content) VALUES (?, ?)")
// .bind(slug)
// .bind(cached_page)
// .execute(&self.memory)
// .await?;
// Ok(())
// }
//
pub async fn add_page<S, T>(&self, slug: S, content: T) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
let slug = prepare_slug(slug.as_ref()).await;
sqlx::query("INSERT INTO pages (slug, content) VALUES (?, ?)")
.bind(slug)
.bind(content.as_ref())
.execute(&self.memory)
.await?;
Ok(())
}
pub async fn get_page<S>(&self, slug: S) -> anyhow::Result<String>
where
S: AsRef<str>,
{
let slug = prepare_slug_query(slug.as_ref()).await;
let page: (String,) = sqlx::query_as("SELECT content FROM pages WHERE slug=?")
.bind(slug)
.fetch_one(&self.memory)
.await?;
Ok(page.0)
}
pub async fn update_page<S, T>(&self, slug: S, content: T) -> anyhow::Result<()>
where
S: AsRef<str>,
T: AsRef<str>,
{
let slug = prepare_slug(slug.as_ref()).await;
sqlx::query("REPLACE INTO pages (slug, content) VALUES (?, ?)")
.bind(slug)
.bind(content.as_ref())
.execute(&self.memory)
.await?;
Ok(())
}
pub async fn delete_page<S>(&self, slug: S) -> anyhow::Result<()>
where
S: AsRef<str>,
{
let slug = prepare_slug(slug.as_ref()).await;
sqlx::query("DELETE FROM pages WHERE slug=?")
.bind(slug)
.execute(&self.memory)
.await?;
Ok(())
}
pub async fn add_pages<P, S, C>(&self, pages: P) -> anyhow::Result<()>
where
P: IntoIterator<Item = (S, C)>,
S: AsRef<str>,
C: AsRef<str>,
{
let pages = pages.into_iter();
for page in pages.into_iter() {
let slug = prepare_slug(page.0.as_ref()).await;
let content = page.1.as_ref();
let result: (String, String) =
sqlx::query_as("REPLACE INTO pages (slug, content) VALUES (?,?) RETURNING *")
.bind(slug)
.bind(content)
.fetch_one(&self.memory)
.await?;
}
Ok(())
}
pub async fn get_pages(&self) -> anyhow::Result<Vec<(String, String)>> {
let assets: Vec<(String, String)> = sqlx::query_as("SELECT * FROM assets")
.fetch_all(&self.db)
.await?;
Ok(assets)
}
}
/// Combine multiple patterns that match a page (e.g. `/blog/index.html` and `/blog/`)
async fn prepare_slug<'a>(mut slug: &'a str) -> &str {
if slug.ends_with(".html") {
slug = slug.strip_suffix(".html").unwrap()
} else if slug.ends_with("/index.html") {
slug = slug.strip_suffix("/index.html").unwrap()
}
if slug.ends_with("/") {
slug = slug.strip_suffix("/").unwrap()
}
slug
}
/// Combine multiple patterns that match a page (e.g. `/blog/index.html` and `/blog/`)
async fn prepare_slug_query<'a>(mut slug: &'a str) -> &str {
if slug.ends_with(".html") {
slug = slug.strip_suffix(".html").unwrap()
} else if slug.ends_with("/index.html") {
slug = slug.strip_suffix("/index.html").unwrap()
}
if slug.ends_with("/") {
slug = slug.strip_suffix("/").unwrap()
}
if slug == "" {
slug = "index"
}
slug
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::tests::*;
use sqlx::SqlitePool;
// struct PostCacher;
// impl Cacher<Post> for PostCacher {
// fn cache(&self, cacheable: Post) -> anyhow::Result<String> {
// Ok("cached page!".to_string())
// }
// }
// #[sqlx::test]
// async fn cache(pool: SqlitePool) -> anyhow::Result<()> {
// let (db, session_id, post) = init_and_add_post(pool).await?;
// db.cache_post(post.id, PostCacher).await?;
// Ok(())
// }
}

97
src/posts.rs Normal file
View File

@ -0,0 +1,97 @@
use sqlx::prelude::FromRow;
use crate::BlogDb;
#[derive(Clone, FromRow, Debug, Default)]
pub struct Post {
pub id: String,
pub title: String,
pub date: String,
// Valid markdown
pub content: String,
// Separated by commas, no whitespace
pub tags: String,
}
impl BlogDb {
/// Create a single post with the given data.
///
/// This is mostly used for testing: most of the time you should use [`BlogDb::publish_draft`] to publish a post from a draft
pub async fn add_post<S, T, R>(&self, title: S, tags: T, content: R) -> anyhow::Result<Post>
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
{
let post: Post = sqlx::query_as(
"REPLACE INTO posts (title, date, tags, content) VALUES (?, date(), ?, ?) RETURNING *",
)
.bind(title.as_ref())
.bind(tags.as_ref())
.bind(content.as_ref())
.fetch_one(&self.db)
.await?;
Ok(post)
}
/// Get a single post by id
pub async fn get_post<S>(&self, id: S) -> anyhow::Result<Post>
where
S: AsRef<str>,
{
let post: Post = sqlx::query_as("SELECT * FROM posts WHERE id=?")
.bind(id.as_ref())
.fetch_one(&self.db)
.await?;
Ok(post)
}
/// Get all posts
pub async fn get_posts(&self) -> anyhow::Result<Vec<Post>> {
let posts: Vec<Post> = sqlx::query_as("SELECT * FROM posts")
.fetch_all(&self.db)
.await?;
Ok(posts)
}
/// Delete post by id
pub async fn delete_post<S>(&self, id: S) -> anyhow::Result<()>
where
S: AsRef<str>,
{
sqlx::query_as::<_, Post>("DELETE FROM posts WHERE id=? RETURNING *")
.bind(id.as_ref())
.fetch_one(&self.db)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::util::tests::*;
use sqlx::SqlitePool;
#[sqlx::test]
async fn posts(pool: SqlitePool) -> anyhow::Result<()> {
let (db, session_id) = get_init_db(pool).await?;
let title = "ʕ·ᴥ·ʔ";
let content = "ʕ·ᴥ·ʔ- hello there";
let tags = "george";
let post = db.add_post(title, tags, content).await?;
let id = post.id;
let post = db.get_post(id).await?;
assert_eq!(post.title, title.to_string());
assert_eq!(post.content, content.to_string());
assert_eq!(post.tags, tags.to_string());
db.delete_post(id).await?;
let deleted = db.get_post(id).await.is_err();
assert!(deleted);
Ok(())
}
}

475
src/users.rs Normal file
View File

@ -0,0 +1,475 @@
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(())
}
}

54
src/util.rs Normal file
View File

@ -0,0 +1,54 @@
use anyhow::{anyhow, Context};
use argon2::{password_hash::SaltString, Argon2};
use rand::rngs::OsRng;
use argon2::{PasswordHash, PasswordHasher, PasswordVerifier};
use sqlx::SqlitePool;
use tokio::task;
pub(super) async fn verify_hashed_value(value: String, hash: String) -> anyhow::Result<()> {
task::spawn_blocking(move || {
let hash = PasswordHash::new(&hash)?;
Argon2::default()
.verify_password(value.as_bytes(), &hash)
.map_err(|_| anyhow!("Couldn't verify hashed value"))
})
.await?
}
pub(super) async fn hash_value(value: String) -> anyhow::Result<String> {
task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
Ok(argon2.hash_password(value.as_bytes(), &salt)?.to_string())
})
.await?
}
#[cfg(test)]
pub(super) mod tests {
use crate::*;
use posts::Post;
use sqlx::{ConnectOptions, SqlitePool};
pub(crate) async fn get_init_db(pool: SqlitePool) -> anyhow::Result<(BlogDb, i64)> {
let user = "evie";
let password = "hunter2".to_string();
let db_url = pool.connect_options().to_url_lossy().to_string();
let db = BlogDb::new(user, password.clone(), db_url).await?;
let session_id = db.new_session(user, password, "+1 year").await?;
Ok((db, session_id))
}
pub(crate) async fn init_and_add_post(pool: SqlitePool) -> anyhow::Result<(BlogDb, i64, Post)> {
let (db, session_id) = get_init_db(pool).await?;
let post = db.add_post("hello", "", "").await?;
let resulting_post = db.get_post(post.id).await?;
assert_eq!(resulting_post.title, "hello");
assert_eq!(resulting_post.tags, "");
assert_eq!(resulting_post.content, "");
Ok((db, session_id, post))
}
}