Initial commit
This commit is contained in:
commit
d7798783ba
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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"] }
|
|
@ -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
|
||||||
|
);
|
|
@ -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
|
||||||
|
);
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
// }
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue