Reorganize repo into cargo workspace

This commit is contained in:
august kline 2024-12-14 15:37:35 -05:00
parent cc5af850ce
commit 83b7997c6c
52 changed files with 20210 additions and 0 deletions

3
blogdb/.gitignore vendored Normal file
View File

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

1704
blogdb/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
blogdb/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "blogdb"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.89"
argon2 = { version = "0.5.3", features = ["std"] }
dotenvy = "0.15.7"
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
blogdb/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
blogdb/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(())
}
}

75
blogdb/src/lib.rs Normal file
View File

@ -0,0 +1,75 @@
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,
};
/// 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>(username: S, password: String) -> anyhow::Result<Self>
where
S: AsRef<str>,
{
let main_db_name = dotenvy::var("DB_MAIN_PATH").unwrap_or("main.db".to_string());
let cache_name = dotenvy::var("DB_CACHE_PATH").unwrap_or("cache.db".to_string());
if File::open(&main_db_name).is_err() {
File::create_new(&main_db_name)?;
}
if File::open(&cache_name).is_err() {
File::create_new(&cache_name)?;
}
let db = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::from_str(&["sqlite://", &main_db_name].concat())?
.journal_mode(SqliteJournalMode::Wal),
)
.await?;
let memory = SqlitePoolOptions::new()
.min_connections(1)
.connect_with(
// #[cfg(not(debug_assertions))]
// SqliteConnectOptions::from_str(MEMORY_URL)?.journal_mode(SqliteJournalMode::Memory),
// #[cfg(debug_assertions)]
SqliteConnectOptions::from_str(&["sqlite://", &cache_name].concat())?
.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
blogdb/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(())
// }
}

107
blogdb/src/posts.rs Normal file
View File

@ -0,0 +1,107 @@
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 ORDER BY DATE(date) DESC")
.fetch_all(&self.db)
.await?;
Ok(posts)
}
/// Get `count` number of posts
pub async fn get_count_posts(&self, count: u32) -> anyhow::Result<Vec<Post>> {
let posts: Vec<Post> =
sqlx::query_as("SELECT * FROM posts LIMIT ? ORDER BY DATE(date) DESC")
.bind(count)
.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
blogdb/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
blogdb/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))
}
}

51
server/Cargo.toml Normal file
View File

@ -0,0 +1,51 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
blogdb = { path = "../blogdb/" }
anyhow = "1.0.89"
argon2 = "0.5.3"
axum = { version = "0.7.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9.4", features = [
"cookie",
"cookie-private",
"form",
"multipart",
"query",
] }
serde = { version = "1.0.210", features = ["derive"] }
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "macros"] }
tokio = { version = "1.40.0", features = ["full"] }
tower-http = { version = "0.6.1", features = [
"cors",
"fs",
"limit",
"normalize-path",
"trace",
] }
tracing = "0.1.40"
futures = "0.3.31"
tower = "0.5.1"
http-body = "1.0.1"
lol_html = "2.0.0"
tokio-util = { version = "0.7.12", features = ["io"] }
glob = "0.3.1"
mime_guess = "2.0.5"
serde_json = "1.0.132"
tantivy = "0.22.0"
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
"serde",
"serde_json",
"std",
] }
phf = "0.11.2"
[build-dependencies]
dotenvy = "0.15.7"
glob = "0.3.1"
phf = "0.11.2"
phf_codegen = "0.11.2"
constcat = "0.5.1"

108
server/build.rs Normal file
View File

@ -0,0 +1,108 @@
use std::{
env,
fs::File,
io::Write,
io::{BufWriter, Read},
path::Path,
};
use dotenvy::{dotenv, dotenv_iter};
use glob::glob;
use phf_codegen::Map;
fn main() {
println!("cargo:rerun-if-changed={}", dotenv().unwrap().display());
for item in dotenv_iter().unwrap() {
let (key, value) = item.unwrap();
println!("cargo:rustc-env={key}={value}");
}
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
let mut file = BufWriter::new(File::create(&path).unwrap());
let template_dir = [&env::var("CARGO_MANIFEST_DIR").unwrap(), "/src/templates"].concat();
println!("cargo:rerun-if-changed={}", template_dir);
let pattern = [&template_dir, "/**/*.html"].concat();
let mut map: Map<String> = phf_codegen::Map::new();
let paths = glob(&pattern).unwrap();
for path in paths {
match path {
Ok(path) => {
let slug_path = path.clone();
let slug: String = slug_path
.strip_prefix(&template_dir)
.unwrap()
.as_os_str()
.to_str()
.unwrap()
.strip_suffix(".html")
.unwrap()
.into(); // eek
let mut file = File::open(path.clone()).unwrap();
let mut content = "r####\"".to_string();
file.read_to_string(&mut content).unwrap();
content.push_str("\"####");
// println!("cargo:warning={}\n{}", &slug, &content);
map.entry(slug, &content);
}
Err(_) => todo!(),
}
}
writeln!(
&mut file,
"static TEMPLATES: phf::Map<&'static str, &'static str> = {};",
map.build()
)
.unwrap();
// Get slug and absolute path of all assets
// In our actual source:
// ```
// let assets: Vec<(String, Vec<u8>)> = vec![]
// for (slug, path) in ASSET_PATHS {
// let asset = (slug, include_bytes!(path));
// assets.push(asset);
// }
// db.add_assets(assets).await?
// ```
let assets_dir = [
&env::var("CARGO_MANIFEST_DIR").unwrap(),
"/src/client/assets",
]
.concat();
println!("cargo:rerun-if-changed={}", assets_dir);
let pattern = [&assets_dir, "/**/*.*"].concat();
let paths = glob(&pattern).unwrap();
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("add_assets.rs");
let mut file = BufWriter::new(File::create(&path).unwrap());
writeln!(
&mut file,
r#"{{let mut assets: Vec<(&str, &[u8])> = vec![];"#
)
.unwrap();
let mut ident_name = String::new();
for path in paths {
match path {
Ok(path) => {
ident_name.push('a');
let slug_path = path.clone();
let slug: String = slug_path
.strip_prefix(&assets_dir)
.unwrap()
.as_os_str()
.to_str()
.unwrap()
.into(); // eek
// println!("cargo:warning={}{:?}", &slug, &path);
writeln!(
&mut file,
r#"assets.push(("{slug}",include_bytes!("{}")));"#,
path.to_str().unwrap()
)
.unwrap();
}
Err(_) => todo!(),
}
}
writeln!(&mut file, r#"db.add_assets(assets).await?;}}"#).unwrap();
}

View File

@ -0,0 +1,256 @@
main {
--page-margin: calc(0.333 * 100vi / 2);
--page-content-size: calc(100% - (var(--page-margin) * 2));
display: flex;
flex-direction: column;
gap: var(--default-padding);
margin-block-end: 40svb;
padding: 0;
}
@media screen and (min-width: 60rem) {
main {
block-size: calc(100svb - 8rem
/*the most magic number of all...*/
);
margin-block-end: unset;
max-inline-size: var(--page-content-size);
}
.blog-admin {
flex-wrap: nowrap !important;
}
.admin-widget-user {
flex-direction: row !important;
justify-content: space-between;
}
.admin-widget ul {
display: flex;
flex-direction: column;
justify-content: start;
flex: 1;
min-block-size: 0;
block-size: 100%;
overflow: auto;
max-block-size: 100%;
margin-block: 0;
gap: var(--default-padding);
li {
@media screen and (min-width: 60rem) {
min-block-size: 6rem;
flex: 0 1 6rem;
}
}
}
}
.admin-widget-user {
padding: var(--default-padding);
flex-direction: column;
gap: var(--default-padding);
&>* {
align-self: flex-start;
}
#user-info {
display: flex;
position: absolute;
z-index: -1;
transition: opacity 0.3s ease;
transform: translate(calc(var(--default-padding) * -1), calc(var(--default-padding) * -1));
box-sizing: border-box;
opacity: 0;
text-wrap: nowrap;
inline-size: 100%;
block-size: 100%;
background: var(--color-bg);
.close {
aspect-ratio: 1;
border: none;
box-shadow: none;
}
&:target {
z-index: 100;
opacity: 1;
}
form {
align-items: stretch;
justify-content: stretch;
}
.user-info-form {
display: contents;
&>* {
padding-block: 0;
min-inline-size: 0;
}
}
}
&>*:not(#user-info) {
transition: all 0.3s ease;
}
&:has(#user-info:target) {
&>*:not(#user-info) {
opacity: 0;
}
}
}
.admin-widget:not(.admin-widget-user) {
min-block-size: 0;
box-sizing: border-box;
flex: 1;
flex-direction: column;
}
.blog-admin {
box-sizing: border-box;
display: flex;
gap: var(--default-padding);
flex-wrap: wrap;
box-sizing: border-box;
min-block-size: 0;
flex: 1;
justify-content: stretch;
ul {
padding-inline: 0;
}
form {
flex-direction: column;
gap: var(--default-padding);
box-sizing: border-box;
min-block-size: 0;
flex: 0 1 100%;
.empty-message {
@media screen and (min-width: 60rem) {
margin-block-start: 50%;
transform: translateY(-50%);
}
align-self: center;
}
.form-action {
transition: all 0.3s ease;
visibility: hidden;
opacity: 0;
min-block-size: 0;
}
.form-actions {
border-block-start: var(--border);
transition: all 0.3s ease;
display: flex;
gap: var(--default-padding);
box-sizing: content-box;
justify-content: end;
max-block-size: 0;
padding: 0 var(--default-padding);
min-block-size: 0;
}
&:has(:checked) {
.form-actions {
border-block: var(--border);
transition: all 0.3s ease;
max-block-size: calc(var(--default-padding) * 2);
padding-block: var(--default-padding);
@media screen and (max-width: 500px) {
& {
max-block-size: 4rem;
}
}
}
.form-action {
transition: all 0.3s ease;
visibility: visible;
opacity: 1;
}
}
input[type="checkbox"] {
min-inline-size: 2rem;
}
}
}
.admin-widget {
position: relative;
min-block-size: 0;
border: var(--border);
display: flex;
&>*:not(form, a) {
padding-inline: var(--default-padding);
}
.widget-header {
display: flex;
min-block-size: 0;
align-items: center;
justify-content: space-between;
}
li {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(2 * var(--default-padding)) var(--default-padding);
border-inline-start: 0px solid black;
}
.widget-entry {
transition: all 0.2s ease;
&:has(.entry-content:hover),
&:has(.entry-content:focus) {
border-inline-start-width: 3px;
transition: all 0.2s ease;
}
}
.entry-content {
&>* {
margin: 0;
}
}
a.entry-content {
color: inherit;
text-decoration: inherit;
inline-size: 90%;
transition: all 0.3s ease;
&:hover,
&:focus {
transform: translateX(1ch);
}
&:focus {
outline: none;
}
}
}

View File

@ -0,0 +1,44 @@
main {
display: block;
}
h1 {
margin-block: 0;
}
.blog-roll-entry {
margin-block: 1rem;
a {
border: var(--border);
background: var(--color-bg-accent);
display: flex;
flex-direction: column;
align-items: start;
outline: none;
--content-padding: 1rem;
.entry-content {
padding: var(--content-padding);
transition: padding-inline-start 0.3s ease, box-shadow 0.3s ease;
* {
margin-block: 0;
}
}
&:hover,
&:focus-visible {
.entry-content {
padding-inline-start: calc(var(--content-padding) * 2);
box-shadow: inset 3px 0px 0px var(--color-text);
}
}
}
img {
border-inline: none;
border-block-start: none;
}
}

View File

@ -0,0 +1,197 @@
main {
@media screen and (min-width: 60rem) {
min-block-size: calc(100svb - 8rem
/*the most magic number of all...*/
) !important;
}
margin-block-end: 0;
border: solid 1px var(--color-text);
padding: 2rem;
min-block-size: 80%;
/* display: flex; */
flex-direction: column;
align-items: stretch;
h1 {
font-size: calc(var(--font-size) * 3.33);
}
h2 {
font-size: calc(var(--font-size) * 2.66);
}
h3 {
font-size: calc(var(--font-size) * 2);
}
:is(h4, h5, h6) {
font-size: calc(var(--font-size) * 1.33);
}
:is(h1,
h2,
h3,
h4,
h5,
h6) {
margin-block: 2rem 0.5rem;
}
}
.ce-block:not(:first-child) {
*:is(h1,
h2,
h3,
h4,
h5,
h6) {
margin-block: 0rem 0.5rem !important;
}
}
.ce-header {
&::before,
&::after {
font-family: var(--font-family-display) !important;
}
}
.ce-delimiter {
width: 100%;
margin-block: 1rem;
text-align: left !important;
}
.cdx-input {
border-radius: 0 !important;
border: var(--border) !important;
background-color: var(--color-bg) !important;
}
.ce-delimiter::before {
display: block;
content: "" !important;
height: 1px !important;
inline-size: 100%;
position: absolute;
background: var(--color-text);
color: green
}
.image-tool__image_preloader::after {
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
}
@media all and (max-width: 650px) {
.ce-toolbar__actions {
&>*:nth-child(1),
&>*:nth-child(2) {
border-radius: 0;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-text);
}
}
}
.cdx-warning {
&::before {
display: none !important;
inline-size: 0 !important;
}
.cdx-warning__message {
display: none;
}
.cdx-warning__title {
margin-bottom: 0;
padding: 0;
}
.cdx-input[data-placeholder]::before {
display: none !important;
}
.cdx-input {
box-shadow: none;
-webkit-box-shadow: none;
border: none;
}
font-size: calc(var(--font-size) * 1.3);
border: 1px solid var(--color-text);
border-inline-start: 4px solid var(--color-text);
padding: 1rem 2rem !important;
max-inline-size: 100%;
margin-inline: 0;
}
.codex-editor__redactor {
display: flex;
flex-direction: column;
gap: var(--default-padding);
}
* {
border-radius: 0 !important;
}
.ce-block__content {
word-wrap: break-word;
}
.ce-block {
margin-block: 0;
padding-block: 0;
}
.cdx-block {
margin-block: 0;
padding-block: 0;
}
.codex-editor {
&>*:first-child {
& h1,
h2,
h3,
h4 {
padding-block-start: 0;
}
}
}
.ce-popover--opened>.ce-popover__container {
border: solid 1px var(--color-text) !important;
border-radius: 0;
background: var(--color-bg);
color: var(--color-text);
}
.ce-paragraph {
font-size: calc(var(--font-size) * 1.3);
line-height: calc(var(--font-size) * 2);
}
.cdx-block {
&>span {
background-color: var(--color-selection) !important;
}
}
.ce-block--selected .ce-block__content {
background-color: var(--color-selection) !important;
}
.embed-tool__caption {
display: none;
}

View File

@ -0,0 +1,73 @@
body {
display: flex;
flex-wrap: wrap;
justify-content: stretch;
align-items: stretch;
}
@media screen and (max-width: 50rem) {
body {
flex-direction: column;
flex-wrap: nowrap;
}
}
body>div {
display: block;
object-fit: cover;
all: unset;
max-block-size: 100%;
min-block-size: 25%;
min-inline-size: 50%;
max-inline-size: 100%;
background: url("/assets/images/login.jpg");
filter: invert();
background-size: cover;
mask-size: 100%;
mask-image: linear-gradient(rgb(0 0 0 / 100%), transparent);
@media screen and (min-width: 50rem) {
mask-image: linear-gradient(90deg, rgb(0 0 0 / 100%), transparent);
}
image-rendering: pixelated;
}
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* block-size: 100%; */
max-block-size: 100%;
&>:first-child {
display: flex;
flex-direction: column;
border: var(--border);
padding: 1rem;
align-items: center;
h1 {
align-self: center;
}
}
}
label:has(input:not([type="checkbox"])) {
display: flex;
flex-direction: column;
}
button {
padding: 0.5ch;
}
form {
display: flex;
flex-direction: column;
max-inline-size: 40ch;
gap: 1rem;
}

View File

@ -0,0 +1,434 @@
@font-face {
src: url("/assets/fonts/Redaction-Regular.woff2");
font-family: Redaction;
}
@font-face {
src: url("/assets/fonts/Atkinson-Hyperlegible-Regular.woff2");
font-family: Atkinson-Hyperlegible;
}
:root {
--color-bg: #faf9f6;
--color-bg-accent: #fffefb;
--color-text: #000000;
--color-accent: oklch(70.92% 0.1619 310);
--color-selection: color-mix(in srgb, var(--color-bg) 85%, var(--color-accent) 15%);
--font-family-display: Redaction;
--font-family-text: Atkinson-Hyperlegible;
--border: 1px solid var(--color-text);
--default-padding: 1rem;
--transition-timing: 0.3s ease;
--header-size: 6rem;
--font-size: 1rem;
font-size: var(--font-size);
line-height: 1.15;
/* 1. Correct the line height in all browsers. */
-webkit-text-size-adjust: 100%;
/* 2. Prevent adjustments of font size after orientation changes in iOS. */
tab-size: 4;
/* 3. Use a more readable tab size (opinionated). */
color: var(--color-text);
scroll-behavior: smooth;
*::selection {
background-color: var(--color-selection);
}
}
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
html,
body {
inline-size: 100%;
block-size: 100%;
margin: 0;
background: var(--color-bg);
}
*,
::before,
::after {
box-sizing: border-box;
font-family: var(--font-family-text);
}
p,
li,
button,
.button,
input,
label,
a,
blockquote,
aside,
ol,
ul {
font-size: calc(var(--font-size) * 1.33);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-family-display);
}
aside,
blockquote {
border: var(--border);
}
blockquote,
aside {
padding: var(--default-padding) calc(var(--default-padding) * 2);
max-inline-size: 100%;
margin-inline: 0;
}
blockquote {
&::before {
content: '“';
font-weight: 600;
}
&::after {
content: '”';
font-weight: 600;
}
}
ul {
appearance: none;
list-style: none;
padding-inline: unset;
}
a {
color: unset;
text-decoration: unset;
}
form {
display: flex;
}
input {
appearance: none;
-webkit-appearance: none;
background: var(--color-bg-accent);
outline: none;
border: var(--border);
transition: box-shadow var(--transition-timing);
margin: 0;
}
input:is([type="text"], [type="password"], [type="search"]) {
padding: 0.5ch 1ch;
&:focus,
&:hover {
box-shadow: inset 3px 0px 0px var(--color-text);
}
}
input[type="checkbox"] {
min-inline-size: 1ch;
align-content: center;
justify-content: center;
display: inline-block;
cursor: pointer;
&:focus-visible {
outline: none;
border: 0px solid var(--color-bg);
outline: 0px solid var(--color-text);
&:not(:checked)::before {
outline: 3px solid var(--color-text);
}
&:checked::before {
outline: 3px solid var(--color-text);
border: 3px solid var(--color-bg);
}
}
&::before {
content: "";
display: block;
aspect-ratio: 1;
background: var(--color-bg);
outline: 1px solid var(--color-text);
box-shadow: inset 0px 0px 0px var(--color-text), inset 0px 0px 0px var(--color-text);
}
&,
&::before {
transition: box-shadow var(--transition-timing), background var(--transition-timing), outline var(--transition-timing);
}
&:hover::before {
box-shadow: inset 1px 1px 0px var(--color-text), inset -1px -1px 0px var(--color-text);
}
&:checked::before {
background: var(--color-text);
}
}
button,
.button {
appearance: unset;
background: var(--color-bg);
color: var(--color-text);
border: var(--border);
transition: box-shadow var(--transition-timing);
cursor: pointer;
display: inline-block;
outline: none;
&:hover,
&:focus-visible {
box-shadow: 0px 2px 0px var(--color-text);
}
&:active {
box-shadow:
0px 1px 0px var(--color-text);
}
}
.button {
padding: 1px 4px;
}
.animated-link {
background-size: 100% 0%;
}
.animated-link-underline {
background-size: 100% 1px;
}
.animated-link,
.animated-link-underline {
text-decoration: unset;
background-position: left bottom;
background-repeat: no-repeat;
transition: background-size var(--transition-timing), background-image var(--transition-timing), color var(--transition-timing);
background-image: linear-gradient(var(--color-text), var(--color-text));
color: var(--color-text);
* {
transition: color var(--transition-timing);
color: var(--color-text);
}
&:hover,
&:focus,
&:focus-visible {
outline: none;
color: var(--color-bg);
* {
color: var(--color-bg);
}
background-size: 100% 100%;
}
}
header {
box-sizing: content-box;
flex-direction: column;
border-block-end: var(--border);
max-inline-size: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: var(--default-padding);
position: sticky;
z-index: 100;
top: 0;
background: var(--color-bg);
&>:first-child {
margin: unset;
}
h1 {
margin-block: 0.25ch;
text-align: center;
}
nav {
flex-wrap: wrap;
max-inline-size: 100%;
display: flex;
align-items: center;
justify-content: center;
a {
all: unset;
cursor: pointer;
font-family: var(--font-family-display);
}
&:first-child {
margin-inline-start: 0;
}
&:last-child {
margin-inline-end: 0;
}
gap: var(--default-padding);
font-size: calc(var(--font-size) * 1.8);
}
form {
min-inline-size: min(100%, 10ch);
flex: 0;
align-items: center;
border: var(--border);
background: var(--color-bg-accent);
button {
display: flex;
pointer-events: none;
padding-inline-end: 0.25rem;
}
input {
min-inline-size: 0;
flex: 1;
}
transition: flex var(--transition-timing),
padding-inline var(--transition-timing);
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
display: none;
}
&>* {
box-sizing: content-box;
background: transparent;
margin-inline: 0;
margin-block: 0;
padding-block: 0;
block-size: 100%;
border: none;
}
}
}
main {
margin-block: var(--default-padding) 40svb;
margin-inline: auto;
max-inline-size: min(60ch, 80%);
padding-block: var(--default-padding);
gap: var(--default-padding);
display: flex;
flex-direction: column;
&>:first-child {
margin-block-start: 0;
&:is(a) {
&+* {
margin-block-start: 0rem !important;
}
}
}
&>* {
margin-block: 0;
}
&>* {
&:is(h1) {
font-size: calc(var(--font-size) * 3.33);
}
&:is(h2) {
font-size: calc(var(--font-size) * 2.66);
}
&:is(h3) {
font-size: calc(var(--font-size) * 2);
}
&:is(h4, h5, h6) {
font-size: calc(var(--font-size) * 1.33);
}
&:is(h1,
h2,
h3,
h4,
h5,
h6) {
margin-block: 2rem 0.5rem;
}
}
img {
min-inline-size: 100%;
max-inline-size: 100%;
object-fit: cover;
object-position: top;
border: var(--border);
}
}
/* anything wider than mobile */
@media all and (min-width: 60rem) {
header {
box-sizing: border-box;
flex-direction: row;
justify-content: space-between;
nav {
justify-content: flex-end;
box-sizing: border-box;
}
}
main {
margin-block: var(--default-padding);
}
}
/* tablet */
@media all and (min-width: 60rem) and (max-width: 80rem) {}
/* desktop */
@media all and (min-width: 80rem) {}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

File diff suppressed because one or more lines are too long

769
server/src/html.rs Normal file
View File

@ -0,0 +1,769 @@
use std::{env, vec};
use blogdb::posts::Post;
use blogdb::BlogDb;
use lol_html::html_content::ContentType;
use lol_html::{comments, element, rewrite_str, RewriteStrSettings};
use crate::routes::api::editor::Blocks;
use crate::routes::SearchResponse;
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
/// Gets template html string from `src/templates`
///
/// # Examples
///
/// ```
/// // `src/templates/blog-roll.html`
/// //
/// // <h1>Blog</h1>
/// // <ul></ul>
///
/// let template = template!("blog-roll");
/// assert_eq!(template, "<h1>Blog</h1>\n<ul></ul>");
/// ```
macro_rules! template {
($t:tt) => {
TEMPLATES.get($t).unwrap_or(&"")
};
}
/// Makes a page with default settings and content inserted into <main></main>
/// (or replacing <main></main> if content has a <main> tag)
pub(crate) fn make_page<S>(content: S, settings: PageSettings) -> String
where
S: AsRef<str>,
{
rewrite_str(
template!("default"),
RewriteStrSettings {
element_content_handlers: vec![
element!("head", |head| {
head.prepend(
&["<title>", &settings.title, "</title>"].concat(),
ContentType::Html,
);
if let Some(stylesheets) = &settings.stylesheets {
for url in stylesheets {
head.append(
&[r#"<link rel="stylesheet" href=""#, url, r#"" />"#].concat(),
ContentType::Html,
)
}
}
Ok(())
}),
element!("body", |body| {
body.prepend("<script>0</script>", ContentType::Html);
Ok(())
}),
element!("main", |main| {
if content.as_ref().contains("<main>") {
main.replace(content.as_ref(), ContentType::Html);
} else {
main.set_inner_content(content.as_ref(), ContentType::Html);
}
Ok(())
}),
element!("site-header", |site_header| {
if settings.site_header {
site_header.replace(template!("site-header"), ContentType::Html);
}
Ok(())
}),
element!("br", |br| {
br.remove();
Ok(())
}),
comments!("*", |comments| {
comments.remove();
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn rewrite_el<S, T, R>(input: S, name: T, with: R) -> String
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!(name.as_ref(), |el| {
el.replace(with.as_ref(), ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn remove_el<S, T>(input: S, name: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!(name.as_ref(), |el| {
el.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
fn remove_comments<S: AsRef<str>>(input: S) -> String {
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![comments!("*", |co| {
co.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn homepage_editor<S: AsRef<str>>(homepage_content: S) -> String {
editor(homepage_content, "/api/editor/update/home")
}
pub(crate) fn about_editor<S: AsRef<str>>(about_content: S) -> String {
editor(about_content, "/api/editor/update/about")
}
pub(crate) fn draft_editor<S, T>(post_content: S, post_id: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
editor(
post_content,
["/api/editor/update/", post_id.as_ref()].concat(),
)
}
fn editor<S, T>(content: S, endpoint: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
let content = content.as_ref();
let data = if !content.is_empty() && content.starts_with("[") && content.ends_with("]") {
content
} else {
"[]"
};
let settings = [
"const data={blocks:",
data,
"};const onChange=()=>{editor.save().then(async(savedData)=>{const endpoint = \"",endpoint.as_ref(),"\"; const response = await fetch(endpoint, { method: \"POST\", mode: \"same-origin\", headers: {\"Content-Type\": \"application/json\"}, body: JSON.stringify(savedData.blocks) });})};"
].concat();
let html = rewrite_str(
template!("post-editor"),
RewriteStrSettings {
element_content_handlers: vec![element!("#editor", move |script| {
script.prepend(&settings, ContentType::Html);
Ok(())
})],
strict: false,
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
html,
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true),
)
}
/// Rewrites `<login-status></login-status>` with html from template, and sets the `next` hidden
/// input element to the next location
pub(crate) fn login_status(next: &str, success: bool) -> String {
let status = if success {
""
} else {
template!("login-status")
};
let next = next.strip_prefix("/").unwrap_or(next);
let html = rewrite_el(template!("login"), "login-status", status);
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
input.set_attribute("value", next).unwrap_or_default();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
input.set_attribute("value", next).unwrap_or_default();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
html,
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false),
)
}
pub(crate) async fn admin_page(session_id: i64, db: &BlogDb) -> String {
let content = admin_widgets(template!("admin"), session_id, db).await;
make_page(
&content,
PageSettings::new("Admin", Some(vec!["/assets/css/admin.css"]), true),
)
}
pub(crate) async fn admin_widgets(input: &str, session_id: i64, db: &BlogDb) -> String {
let posts_html = admin_entries(EntryType::Post, session_id, db).await;
let drafts_html = admin_entries(EntryType::Draft, session_id, db).await;
let user_html = template!("admin-widgets/user");
rewrite_str(
input,
RewriteStrSettings {
element_content_handlers: vec![element!("admin-widget", move |a| {
match a.get_attribute("type") {
Some(attr) => {
match attr.as_str() {
"drafts" => a.replace(&drafts_html, ContentType::Html),
"posts" => a.replace(&posts_html, ContentType::Html),
"user" => a.replace(user_html, ContentType::Html),
_ => a.remove(),
}
Ok(())
}
None => todo!(),
}
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
enum EntryType {
Post,
Draft,
}
async fn admin_entries(entry_type: EntryType, session_id: i64, db: &BlogDb) -> String {
let (list_template, entry_template) = match entry_type {
EntryType::Post => (
template!("admin-widgets/posts"),
template!("admin-widgets/post"),
),
EntryType::Draft => (
template!("admin-widgets/drafts"),
template!("admin-widgets/draft"),
),
};
let is_empty: bool;
let entries_html = match entry_type {
EntryType::Post => {
let mut entries = db.get_posts().await.unwrap_or(vec![]);
entries.reverse();
let mut entry_list_html = String::new();
is_empty = entries.is_empty();
if !is_empty {
for entry in entries {
let href = ["/blog/", &entry.id].concat();
let mut entry_html = admin_entry(
entry_template,
&entry.id,
&entry.title,
&href,
&entry.content,
);
entry_html = rewrite_str(
&entry_html,
RewriteStrSettings {
element_content_handlers: vec![element!("time", |time| {
time.set_inner_content(&entry.date, ContentType::Text);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
entry_list_html.push_str(&entry_html);
}
}
entry_list_html
}
EntryType::Draft => {
let mut entries = db.get_drafts(session_id).await.unwrap_or(vec![]);
entries.reverse();
let mut entry_list_html = String::new();
is_empty = entries.is_empty();
if !is_empty {
for entry in entries {
let href = ["/editor/", &entry.id].concat();
let entry_html = admin_entry(
entry_template,
&entry.id,
&entry.title,
&href,
&entry.content,
);
entry_list_html.push_str(&entry_html);
}
}
entry_list_html
}
};
let final_html = rewrite_str(
list_template,
RewriteStrSettings {
element_content_handlers: vec![element!("ul", |ul| {
if is_empty {
match entry_type {
EntryType::Post => ul.replace("<h2 class=\"empty-message\">No Published Posts</h2>", ContentType::Html),
EntryType::Draft => ul.replace("<div class=\"entry-content empty-message\"><h2>No Drafts</h2><p>Start a <a href=\"/editor/new\" class=\"button\">New Draft</a></p></div>", ContentType::Html)
}
} else {
ul.set_inner_content(&entries_html, ContentType::Html);
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
final_html
}
fn admin_entry(entry_template: &str, id: &str, title: &str, href: &str, content: &str) -> String {
rewrite_str(
entry_template,
RewriteStrSettings {
element_content_handlers: vec![
element!("a.entry-content", |a| {
a.set_attribute("href", href).unwrap_or_default();
Ok(())
}),
element!("h2", |h2| {
h2.set_inner_content(title, ContentType::Html);
Ok(())
}),
element!("p", |p| {
let post_content: Blocks = serde_json::from_str(content).unwrap_or_default();
let content = rewrite_str(
&post_content.desc(),
RewriteStrSettings {
element_content_handlers: vec![element!("*", |all| {
all.remove_and_keep_content();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
p.set_inner_content(&content, ContentType::Text);
Ok(())
}),
element!("input", |input| {
input.set_attribute("value", id).unwrap_or_default();
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) async fn about_page(db: &BlogDb) -> String {
let about = db.get_about().await;
let about_content: Blocks = serde_json::from_str(&about).unwrap_or_default();
let about_html = remove_comments(about_content.to_html());
make_page(&about_html, PageSettings::title("About"))
}
pub(crate) async fn home_page(db: &BlogDb) -> String {
let home = db.get_homepage().await;
let home_content: Blocks = serde_json::from_str(&home).unwrap_or_default();
let home_html = home_content.to_html();
make_page(&home_html, PageSettings::title("Evie Ippolito"))
}
pub(crate) fn insert_blog_widget<S>(input: S) -> String
where
S: AsRef<str>,
{
rewrite_str(
template!("blog"),
RewriteStrSettings {
element_content_handlers: vec![element!("meta", |meta| { Ok(()) })],
..RewriteStrSettings::new()
},
)
.unwrap()
}
fn meta<S, T, R, I>(slug: S, title: T, description: R, image: I) -> String
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
I: AsRef<str>,
{
rewrite_str(
template!("head/meta"),
RewriteStrSettings {
element_content_handlers: vec![element!("meta", |meta| {
if let Some(attr) = meta.get_attribute("property") {
let content = match attr.as_str() {
"og:url" => &[env!("DOMAIN"), slug.as_ref()].concat(),
"og:type" => "article",
"og:title" => title.as_ref(),
"og:description" => description.as_ref(),
"og:image" => {
let image = image.as_ref();
if !image.is_empty() {
image
} else {
meta.remove();
""
}
}
_ => "",
};
let _ = meta.set_attribute("content", content);
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
/// Creates a blog page from a post's content
pub(crate) async fn blog_page<S>(id: S, db: &BlogDb) -> String
where
S: AsRef<str>,
{
let template = template!("blog-page");
let html = make_page(template, PageSettings::title("Blog"));
let post = db.get_post(id).await.unwrap_or_default();
let head = head::<_, _, [String; 0]>(post.title, []);
let post_content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
let post_html: String = post_content.to_html();
let image = if let Some(url) = post_content.image() {
&[url].concat()
} else {
""
};
let meta = meta(
["/blog/", &post.id].concat(),
post_content.title(),
post_content.desc(),
image,
);
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!("time", |time| {
time.after(&post_html, ContentType::Html);
time.replace(
&[
r#"<a class="animated-link" style="align-self: flex-start; margin-block-end: 0" href="https://"#,
env!("DOMAIN"),
"/blog/",
&post.id,
r#""><time>"#,
&post.date,
"</time></a>",
]
.concat(),
ContentType::Html,
);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
html
}
/// Get `count` number of blog entries, if `count` is [None] then get all entries
async fn get_blog_entries_count(db: &BlogDb, count: Option<u32>) -> String {
let posts = match count {
Some(count) => match db.get_count_posts(count).await {
Ok(posts) => posts,
Err(_) => return "".to_string(),
},
None => match db.get_posts().await {
Ok(posts) => posts,
Err(_) => return "".to_string(),
},
};
let mut post_entries = String::new();
for post in posts.iter() {
let post_entry = post_entry(post);
post_entries.push_str(&post_entry);
}
post_entries
}
pub(crate) async fn blog_roll(db: &BlogDb) -> String {
let post_entries = get_blog_entries_count(db, None).await;
let blog_roll_html = rewrite_str(
template!("blog-roll"),
RewriteStrSettings {
element_content_handlers: vec![element!("ul", move |ul| {
ul.set_inner_content(&post_entries, ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
blog_roll_html,
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true),
)
}
fn post_entry(post: &Post) -> String {
rewrite_str(
template!("blog-roll-entry"),
RewriteStrSettings {
element_content_handlers: vec![
element!("a", |a| {
a.set_attribute("href", &["/blog/", &post.id].concat())
.unwrap_or_default();
Ok(())
}),
element!("img", |img| {
let post_content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
match post_content.image() {
Some(src) => {
let _ = img.set_attribute("src", src);
}
None => img.remove(),
}
Ok(())
}),
element!("h2", |h2| {
h2.set_inner_content(&post.title, ContentType::Text);
Ok(())
}),
element!("time", |time| {
time.set_inner_content(&post.date, ContentType::Text);
Ok(())
}),
element!("p", |p| {
let post_content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
let content = remove_el(post_content.desc(), "br");
p.set_inner_content(&content, ContentType::Html);
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn search_page_no_query() -> String {
let search_page_html = make_page(template!("search-page"), PageSettings::title("Search"));
rewrite_str(
&search_page_html,
RewriteStrSettings {
element_content_handlers: vec![element!("ul", move |ul| {
ul.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) async fn search_page(
query: String,
search_response: SearchResponse,
db: &BlogDb,
) -> String {
let mut entries = String::new();
let search_page_html = if !search_response.is_empty() {
for entry in search_response {
let post = match db.get_post(&entry.id[0]).await {
Ok(post) => post,
Err(_) => continue,
};
let entry_html = post_entry(&post);
entries.push_str(&entry_html);
}
[
"<h1>Results for “",
&query,
"”</h1>",
"<ul>",
&entries,
"</ul>",
]
.concat()
} else {
["<h1>No results for “", &query, "”</h1>"].concat()
};
make_page(
&search_page_html,
PageSettings::new(
["Results for “", &query, ""].concat(),
Some(vec!["/assets/css/blog.css"]),
true,
),
)
}
/// Creates a <link rel="stylesheet" href="style_url"/> for each given style_url
fn stylesheets<S, I>(style_urls: I) -> String
where
S: AsRef<str>,
I: IntoIterator<Item = S>,
{
style_urls
.into_iter()
.map(|url| [r#"<link href="#, url.as_ref(), r#" rel="stylesheet"/>"#].concat())
.collect::<String>()
}
fn head<S, T, I>(title: S, style_urls: I) -> String
where
S: AsRef<str>,
T: AsRef<str>,
I: IntoIterator<Item = T>,
{
let content = rewrite_str(
template!("head/content"),
RewriteStrSettings {
element_content_handlers: vec![element!("title", move |t| {
t.set_inner_content(title.as_ref(), ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
let links_html = stylesheets(style_urls);
[content, links_html].concat()
}
pub(crate) struct PageSettings {
title: String,
stylesheets: Option<Vec<String>>,
site_header: bool,
}
impl PageSettings {
pub(crate) fn title<S>(title: S) -> Self
where
S: ToString,
{
Self {
title: title.to_string(),
stylesheets: None,
site_header: true,
}
}
pub(crate) fn new<S, T>(title: S, stylesheets: Option<Vec<T>>, site_header: bool) -> Self
where
S: ToString,
T: ToString,
{
let stylesheets =
stylesheets.map(|s| s.iter().map(|t| t.to_string()).collect::<Vec<String>>());
Self {
title: title.to_string(),
stylesheets,
site_header,
}
}
}
pub(crate) fn make_404<S>(message: S) -> String
where
S: AsRef<str>,
{
make_page(message, PageSettings::title("Not found"))
}
pub(crate) fn animate_anchors<S>(input: S) -> String
where
S: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!("a", move |t| {
match t.get_attribute("class") {
Some(class) => t
.set_attribute("class", &[&class, "animated-link-underline"].concat())
.unwrap_or_default(),
None => {
t.set_attribute("class", "animated-link-underline")
.unwrap_or_default();
}
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn sanitize<S: AsRef<str>>(input: S) -> String {
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!("*", move |all| {
all.remove_and_keep_content();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}

16
server/src/main.rs Normal file
View File

@ -0,0 +1,16 @@
mod html;
mod server;
use std::env;
use server::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let port: Option<u16> = match env::args().nth(1) {
Some(value) => Some(value.parse()?),
None => None,
};
Blog::new().await?.serve(port).await;
Ok(())
}

View File

@ -0,0 +1,43 @@
use axum::{
extract::{Request, State},
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use axum_extra::extract::PrivateCookieJar;
use crate::BlogState;
/// Middleware layer requiring user to log in to access route
pub(crate) async fn logged_in(
jar: PrivateCookieJar,
State(state): State<BlogState>,
request: Request,
next: Next,
) -> Response {
let next_uri = request.uri();
let redirect_to_login = || -> Response {
let mut headers = HeaderMap::new();
headers.append(
LOCATION,
HeaderValue::from_str(&["/login", &next_uri.to_string()].concat()).unwrap(),
);
(StatusCode::FOUND, headers).into_response()
};
let db = state.db();
let session_id = match jar.get("id") {
Some(id) => match id.value_trimmed().parse::<i64>() {
Ok(id) => id,
Err(_) => {
return redirect_to_login();
}
},
None => {
return redirect_to_login();
}
};
if let Err(_error) = db.check_session(session_id).await {
return redirect_to_login();
}
next.run(request).await
}

321
server/src/server/mod.rs Normal file
View File

@ -0,0 +1,321 @@
mod middleware;
pub(crate) mod routes;
use routes::{api::editor::Blocks, make_router};
use tantivy::{
directory::MmapDirectory,
doc,
schema::{Schema, STORED, STRING, TEXT},
Index, IndexWriter, TantivyDocument, Term,
};
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use std::{
env,
fs::{self, File},
io::{Read, Write},
path::PathBuf,
str::FromStr,
sync::Arc,
sync::Mutex,
};
use anyhow::anyhow;
use axum::{
extract::{FromRef, Request},
Router, ServiceExt,
};
use axum_extra::extract::cookie::Key;
use blogdb::BlogDb;
use glob::glob;
use crate::html;
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
const DB_URL: &str = "sqlite://evie.db";
pub struct Blog {
app: Router,
}
impl Blog {
pub async fn new() -> anyhow::Result<Self> {
let state = BlogState::new().await?;
let app = make_router(state).await;
Ok(Self { app })
}
pub async fn serve(self, port: Option<u16>) {
let port = port.unwrap_or(3000);
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.await
.unwrap();
axum::serve(
listener,
ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(self.app),
),
)
.await
.unwrap();
}
}
#[derive(Clone)]
pub struct BlogState(Arc<BlogStateInner>);
impl BlogState {
async fn new() -> anyhow::Result<Self> {
Ok(BlogState(Arc::new(BlogStateInner::new().await?)))
}
fn db(&self) -> &BlogDb {
&self.0.db
}
fn domain(&self) -> String {
self.0.domain.clone()
}
fn index(&self) -> &Index {
&self.0.index
}
fn index_add_document(&self, document: TantivyDocument) {
let _ = self.0.index_writer.lock().unwrap().add_document(document);
}
fn index_delete(&self, term: Term) {
let _ = self.0.index_writer.lock().unwrap().delete_term(term);
}
fn index_commit(&self) {
let _ = self.0.index_writer.lock().unwrap().commit();
}
}
// this impl tells `SignedCookieJar` how to access the key from our state
impl FromRef<BlogState> for Key {
fn from_ref(state: &BlogState) -> Self {
state.0.key.clone()
}
}
struct BlogStateInner {
db: BlogDb,
key: Key,
domain: String,
index: Index,
index_writer: Mutex<IndexWriter>,
}
impl BlogStateInner {
async fn new() -> anyhow::Result<Self> {
let username = env!("INIT_USER_NAME").to_string();
let password = env!("INIT_USER_PASSWORD").to_string();
let domain = option_env!("DOMAIN").unwrap_or("localhost").to_string();
let db = BlogDb::new(&username, password.clone()).await?;
Self::add_initial_assets(&db).await?;
Self::add_initial_pages(&db).await?;
#[cfg(debug_assertions)]
{
Self::add_posts(&db).await?;
Self::add_drafts(&username, password, &db).await?;
}
let key = Self::key()?;
let (index, index_writer) = Self::generate_index(&db).await?;
Ok(Self {
db,
key,
domain,
index,
index_writer: Mutex::new(index_writer),
})
}
async fn add_initial_pages(db: &BlogDb) -> anyhow::Result<()> {
let pages = vec![
("index".to_string(), html::home_page(db).await),
("about".to_string(), html::about_page(db).await),
];
db.add_pages(pages).await?;
Ok(())
}
async fn add_initial_assets(db: &BlogDb) -> anyhow::Result<()> {
include!(concat!(env!("OUT_DIR"), "/add_assets.rs"));
Ok(())
}
const INDEX_PATH: &'static str = "./index";
const MEMORY_BUDGET: usize = 50_000_000;
async fn generate_index(db: &BlogDb) -> anyhow::Result<(Index, IndexWriter)> {
let mut schema_builder = Schema::builder();
let id = schema_builder.add_text_field("id", STRING | STORED);
let title = schema_builder.add_text_field("title", TEXT | STORED);
let body = schema_builder.add_text_field("body", TEXT | STORED);
let schema = schema_builder.build();
let _ = fs::remove_dir_all(Self::INDEX_PATH);
let _ = fs::create_dir_all(Self::INDEX_PATH);
let dir = MmapDirectory::open(Self::INDEX_PATH).unwrap();
let index = Index::open_or_create(dir, schema.clone())?;
let mut index_writer: IndexWriter = index.writer(Self::MEMORY_BUDGET)?;
let posts = db.get_posts().await?;
for post in posts.into_iter() {
let content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
index_writer.add_document(
doc!(id => post.id, title => post.title, body => content.to_plaintext()),
)?;
}
index_writer.commit()?;
Ok((index, index_writer))
}
#[cfg(not(debug_assertions))]
fn key() -> anyhow::Result<Key> {
let key = Key::try_generate().ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
Ok(key)
}
#[cfg(debug_assertions)]
fn key() -> anyhow::Result<Key> {
let key_path = PathBuf::from_str("./key")?;
let key_file = File::options().read(true).write(true).open(&key_path);
let key = match key_file {
Ok(mut file) => {
let mut bytes = vec![];
file.read_to_end(&mut bytes)?;
match Key::try_from(bytes.as_slice()) {
Ok(key) => key,
Err(error) => {
println!("need to make a new key, couldn't get from file cause {error:?}");
let key = Key::try_generate()
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
file.write_all(key.master())?;
println!("made one");
key
}
}
}
Err(error) => {
println!("need to make a new key, couldn't open the key file cause {error:?}");
let key = Key::try_generate()
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
let mut key_file = File::create(&key_path)?;
key_file.write_all(key.master())?;
println!("made one");
key
}
};
Ok(key)
}
#[cfg(debug_assertions)]
async fn add_posts(db: &BlogDb) -> anyhow::Result<()> {
let content = r#"
[
{
"id": "26lQiP26Dd",
"type": "header",
"data": {
"text": "Example Post",
"level": 1
}
},
{
"id": "PnWeuvNW6O",
"type": "header",
"data": {
"text": "This is a subheading",
"level": 2
}
},
{
"id": "xSV-1St5Nh",
"type": "warning",
"data": {
"title": "This is a warning...",
"message": ""
}
},
{
"id": "rfnEtkKIjP",
"type": "quote",
"data": {
"text": "this is a quote<br>",
"caption": "",
"alignment": "left"
}
},
{
"id": "6lQRMa2guf",
"type": "paragraph",
"data": {
"text": "This is some content<br>"
}
}
]
"#;
if db.get_posts().await?.is_empty() {
db.add_post("Example Post", "", content).await?;
}
Ok(())
}
#[cfg(debug_assertions)]
async fn add_drafts(username: &str, password: String, db: &BlogDb) -> anyhow::Result<()> {
let content = r#"
[
{
"id": "26lQiP26Dd",
"type": "header",
"data": {
"text": "Example Draft",
"level": 1
}
},
{
"id": "PnWeuvNW6O",
"type": "header",
"data": {
"text": "This is a subheading",
"level": 2
}
},
{
"id": "xSV-1St5Nh",
"type": "warning",
"data": {
"title": "This is a warning...",
"message": ""
}
},
{
"id": "rfnEtkKIjP",
"type": "quote",
"data": {
"text": "this is a quote<br>",
"caption": "",
"alignment": "left"
}
},
{
"id": "6lQRMa2guf",
"type": "paragraph",
"data": {
"text": "This is some content<br>"
}
}
]
"#;
let session_id = db.new_session(username, password, "+1 year").await?;
if db.get_drafts(session_id).await?.is_empty() {
db.add_draft("Example Draft", "", content).await?;
}
db.end_session(session_id).await?;
Ok(())
}
}

View File

@ -0,0 +1,97 @@
use axum::{
extract::State,
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Response},
routing::post,
Form, Router,
};
use axum_extra::extract::{cookie::Cookie, PrivateCookieJar};
use blogdb::BlogDb;
use serde::Deserialize;
use serde_json::to_string;
use crate::{html, BlogState};
#[derive(Deserialize)]
struct LoginCredentials {
username: Option<String>,
password: Option<String>,
remember: Option<String>,
next: Option<String>,
}
pub(super) fn auth(State(state): State<BlogState>) -> Router {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.with_state(state)
}
async fn login(
mut jar: PrivateCookieJar,
State(state): State<BlogState>,
Form(body): Form<LoginCredentials>,
) -> Response {
let db = &state.db();
let next = match body.next.unwrap_or("/".to_string()).as_str() {
"/" => "/".to_string(),
next => ["/", next].concat(),
};
let username = match body.username {
Some(username) => username,
None => return bad_login(&next).await,
};
let password = match body.password {
Some(password) => password,
None => return bad_login(&next).await,
};
let expiration = if body.remember.is_some() {
"+1 year"
} else {
"+0 day"
};
match db.new_session(username, password, expiration).await {
Ok(id) => {
jar = jar.add(
Cookie::build(("id", id.to_string()))
.domain(state.domain())
.secure(true)
.http_only(true)
.path("/"),
);
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str(&next).unwrap());
(StatusCode::FOUND, headers, jar).into_response()
}
Err(_) => bad_login(&next).await,
}
}
async fn bad_login(next: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
Html(html::login_status(next, false)),
)
.into_response()
}
async fn logout(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
let db = &state.db();
let session_id: i64 = if let Some(cookie) = jar.get("id") {
match cookie.value().parse() {
Ok(id) => id,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
}
} else {
return StatusCode::BAD_REQUEST.into_response();
};
match db.end_session(session_id).await {
Ok(_) => {
let jar = jar.remove("id");
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/").unwrap());
(StatusCode::FOUND, headers, jar).into_response()
}
Err(_) => StatusCode::BAD_REQUEST.into_response(),
}
}

View File

@ -0,0 +1,267 @@
use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
Router,
};
use axum_extra::extract::PrivateCookieJar;
use blogdb::BlogDb;
use serde::{Deserialize, Serialize};
use crate::{
html::{self, about_page, home_page, remove_el},
BlogState,
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data")]
#[allow(non_camel_case_types)]
pub(crate) enum Block {
paragraph {
text: String,
},
header {
text: String,
level: usize,
},
list {
style: ListStyle,
items: Vec<String>,
},
warning {
title: String,
},
quote {
text: String,
},
embed {
service: String,
source: String,
embed: String,
width: usize,
height: usize,
},
image {
file: ImageSource,
#[serde(default)]
caption: String,
},
delimiter {},
}
#[derive(Debug, Deserialize, Serialize)]
#[allow(non_camel_case_types)]
pub struct ImageSource {
url: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[allow(non_camel_case_types)]
pub enum ListStyle {
ordered,
unordered,
}
impl Block {
fn to_html(&self) -> String {
let text = match self {
Block::paragraph { text } => ["<p>", text, "</p>"].concat(),
Block::header { text, level } => {
let level = level.to_string();
["<h", &level, ">", text, "</h", &level, ">"].concat()
}
Block::list { style, items } => match style {
ListStyle::ordered => ["<ol><li>", &items.join("</li><li>"), "</li></ol>"].concat(),
ListStyle::unordered => {
["<ul><li>", &items.join("</li><li>"), "</li></ul>"].concat()
}
},
Block::warning { title } => ["<aside>", title, "</aside>"].concat(),
Block::quote { text } => ["<blockquote>", text, "</blockquote>"].concat(),
Block::embed {
embed,
width,
height,
..
} => [
"<iframe width=\"",
&width.to_string(),
"\" height=\"",
&height.to_string(),
"\" src=\"",
embed,
"\"></iframe>",
]
.concat(),
Block::image { file, caption } => {
["<img src=\"", &file.url, "\" alt=\"", caption, "\"/>"].concat()
}
Block::delimiter {} => "<div style=\"inline-size: 100%; block-size: 1px; background: var(--color-text)\"></div>".to_string(),
};
html::animate_anchors(html::remove_el(&text, "br"))
}
fn to_plaintext(&self) -> String {
let text = match self {
Block::paragraph { text } => text,
Block::header { text, level: _ } => text,
Block::list { style: _, items } => &items.join("\n"),
Block::warning { title } => title,
Block::quote { text } => text,
_ => &"".to_string(),
};
let text = [text, "\n"].concat();
remove_el(&text, "br")
}
}
pub(super) fn editor(State(state): State<BlogState>) -> Router {
Router::new()
.route("/update/:id", post(update))
.route("/update/about", post(update_about))
.route("/update/home", post(update_homepage))
.with_state(state)
}
pub(crate) async fn check_id(
jar: PrivateCookieJar,
db: &BlogDb,
) -> Result<(i64, String), Response> {
match jar.get("id") {
Some(id) => match id.value_trimmed().parse::<i64>() {
Ok(id) => match db.check_session(id).await {
Ok(user) => Ok((id, user)),
Err(_) => Err(StatusCode::UNAUTHORIZED.into_response()),
},
Err(_) => Err(StatusCode::BAD_REQUEST.into_response()),
},
None => Err(StatusCode::UNAUTHORIZED.into_response()),
}
}
async fn update(
jar: PrivateCookieJar,
Path(id): Path<String>,
State(state): State<BlogState>,
Json(content): Json<Blocks>,
) -> Response {
let db = state.db();
if let Err(response) = check_id(jar, db).await {
return response;
}
let title = content.title();
let _ = db.update_draft_title(&id, title).await;
let _ = db
.update_draft_content(&id, serde_json::to_string(&content).unwrap())
.await;
StatusCode::OK.into_response()
}
async fn update_about(
jar: PrivateCookieJar,
State(state): State<BlogState>,
Json(content): Json<Blocks>,
) -> Response {
let db = state.db();
let (id, user) = match check_id(jar, db).await {
Ok(info) => info,
Err(response) => return response,
};
let _ = db
.update_user_about(&user, serde_json::to_string(&content).unwrap(), id)
.await;
let _ = db.update_page("about", about_page(db).await).await;
StatusCode::OK.into_response()
}
async fn update_homepage(
jar: PrivateCookieJar,
State(state): State<BlogState>,
Json(content): Json<Blocks>,
) -> Response {
let db = state.db();
let (id, user) = match check_id(jar, db).await {
Ok(info) => info,
Err(response) => return response,
};
let _ = db
.update_user_homepage(&user, serde_json::to_string(&content).unwrap(), id)
.await;
let _ = db.update_page("index", home_page(db).await).await;
StatusCode::OK.into_response()
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub(crate) struct Blocks(Vec<Block>);
impl Blocks {
pub(crate) fn to_html(&self) -> String {
let mut html = String::new();
for block in self.0.iter() {
html.push_str(&block.to_html());
}
html
}
pub(crate) fn to_plaintext(&self) -> String {
let mut text = String::new();
for block in self.0.iter() {
text.push_str(&block.to_plaintext());
}
text
}
pub(crate) fn first(&self) -> Option<&Block> {
self.0.first()
}
pub(crate) fn title(&self) -> &str {
if let Some(Block::header { text, level: 1 }) = self.first() {
text
} else {
"Untitled"
}
}
pub(crate) fn desc(&self) -> String {
html::sanitize(
self.0
.iter()
.find(|block| match block {
Block::paragraph { text: _ } => true,
Block::header { text: _, level } => *level > 1,
Block::warning { title: _ } => true,
Block::quote { text: _ } => true,
_ => false,
})
.map(|block| match block {
Block::paragraph { text } => text,
Block::header { text, level: _ } => text,
Block::warning { title } => title,
Block::quote { text } => text,
_ => "...",
})
.unwrap_or("No description"),
)
}
pub(crate) fn image(&self) -> Option<&str> {
self.0
.iter()
.find(|block| matches!(block, Block::image { .. }))
.map(|block| match block {
Block::image { file, .. } => Some(file.url.as_str()),
_ => None,
})
.unwrap_or_default()
}
}

View File

@ -0,0 +1,167 @@
use axum::{
extract::{Path, State},
handler::HandlerWithoutStateExt,
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use axum_extra::extract::{Form, PrivateCookieJar, Query};
use serde::Deserialize;
use tantivy::{
doc,
schema::{Schema, STORED, TEXT},
Term,
};
use crate::BlogState;
mod auth;
pub(crate) mod editor;
use auth::auth;
use editor::{check_id, editor, Blocks};
pub(super) fn api(state: BlogState) -> Router {
Router::new()
.route("/posts/:path", post(posts))
.route("/user/update", post(user_update))
.route("/drafts/:path", post(drafts))
.route("/search", get(search))
.with_state(state.clone())
.nest("/auth", auth(State(state.clone())))
.nest("/editor", editor(State(state.clone())))
}
#[derive(Debug, Deserialize)]
struct Entries {
#[serde(default)]
item: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[allow(non_camel_case_types)]
enum PostsEndpoints {
delete,
unpublish,
}
async fn posts(
Path(path): Path<PostsEndpoints>,
State(state): State<BlogState>,
Form(data): Form<Entries>,
) -> Response {
let db = state.db();
let mut schema_builder = Schema::builder();
let id = schema_builder.add_text_field("id", TEXT | STORED);
match path {
PostsEndpoints::delete => {
for post_id in data.item.iter() {
let _ = db.delete_post(post_id).await;
let term = Term::from_field_text(id, post_id);
state.index_delete(term);
}
}
PostsEndpoints::unpublish => {
for post_id in data.item.iter() {
let _ = db.edit_post(post_id).await;
let _ = db.delete_post(post_id).await;
let term = Term::from_field_text(id, post_id);
state.index_delete(term);
}
}
}
state.index_commit();
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
(StatusCode::FOUND, headers).into_response()
}
#[derive(Debug, Deserialize)]
#[allow(non_camel_case_types)]
enum DraftsEndpoints {
delete,
publish,
}
async fn drafts(
Path(path): Path<DraftsEndpoints>,
State(state): State<BlogState>,
Form(data): Form<Entries>,
) -> Response {
let db = state.db();
match path {
DraftsEndpoints::delete => {
for id in data.item.iter() {
let _result = db.delete_draft(id).await;
}
}
DraftsEndpoints::publish => {
for id in data.item.iter() {
match db.publish_draft(id).await {
Ok(post) => {
let mut schema_builder = Schema::builder();
let id = schema_builder.add_text_field("id", TEXT | STORED);
let body = schema_builder.add_text_field("body", TEXT | STORED);
let title = schema_builder.add_text_field("title", TEXT | STORED);
let content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
state.index_add_document(doc!(id => post.id, body => content.to_plaintext(), title => post.title));
}
Err(_) => continue,
};
}
state.index_commit();
}
}
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
(StatusCode::FOUND, headers).into_response()
}
#[derive(Debug, Deserialize)]
struct SearchQuery {
#[serde(default)]
query: Vec<String>,
}
async fn search(Query(query): Query<SearchQuery>) -> Response {
let query = query
.query
.join(" ")
.split(" ")
.collect::<Vec<&str>>()
.join("-");
let url = ["/search/", &query].concat();
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str(&url).unwrap());
(StatusCode::FOUND, headers).into_response()
}
#[derive(Deserialize)]
struct UpdateUserInfo {
username: String,
password: String,
}
async fn user_update(
jar: PrivateCookieJar,
State(state): State<BlogState>,
Form(info): Form<UpdateUserInfo>,
) -> Response {
let db = state.db();
let (id, user) = match check_id(jar, db).await {
Ok(creds) => creds,
Err(resp) => return resp,
};
if info.username != user {
let _ = db.update_user_name(&user, &info.username, id).await;
let _ = db.update_password(user, info.password, id).await;
} else {
let _ = db.update_password(user, info.password, id).await;
}
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
(StatusCode::FOUND, headers).into_response()
}

View File

View File

@ -0,0 +1,76 @@
use axum::{
extract::{Path, State},
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use axum_extra::extract::PrivateCookieJar;
use crate::{html, BlogState};
pub(super) fn login(State(state): State<BlogState>) -> Router {
Router::new()
.route("/", get(login_page))
.route("/*next", get(login_next))
.with_state(state)
}
async fn login_next(
jar: PrivateCookieJar,
Path(path): Path<String>,
State(state): State<BlogState>,
) -> Response {
let db = state.db();
match jar.get("id") {
Some(id) => match id.value_trimmed().parse::<i64>() {
Ok(id) => {
if db.check_session(id).await.is_ok() {
return go_next(path).await;
}
}
Err(_) => {
{};
}
},
None => {
{};
}
}
Html(html::login_status(&path, true)).into_response()
}
async fn login_page(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
let db = state.db();
match jar.get("id") {
Some(id) => match id.value_trimmed().parse::<i64>() {
Ok(id) => {
if db.check_session(id).await.is_ok() {
return go_next("/".to_string()).await;
}
}
Err(_) => {
{};
}
},
None => {
{};
}
}
Html(html::login_status("/", true)).into_response()
}
async fn go_next(path: String) -> Response {
let next = path.as_str();
let mut headers = HeaderMap::new();
let value = match HeaderValue::from_str(next) {
Ok(value) => value,
Err(_error) => {
let mut headers = HeaderMap::new();
headers.append(LOCATION, "/".parse().unwrap());
return (StatusCode::FOUND, headers).into_response();
}
};
headers.append(LOCATION, value);
(StatusCode::FOUND, headers).into_response()
}

View File

@ -0,0 +1,384 @@
pub(crate) mod api;
mod login;
use std::io::Cursor;
use api::{api, editor::check_id};
use axum_extra::{extract::PrivateCookieJar, response::Html};
use login::login;
use axum::{
body::Body,
extract::{rejection::PathRejection, DefaultBodyLimit, Multipart, Path, State},
http::{
header::{CACHE_CONTROL, CONTENT_TYPE, EXPIRES, LOCATION, PRAGMA},
HeaderMap, HeaderValue, StatusCode,
},
middleware::from_fn_with_state,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use tantivy::{
collector::TopDocs,
query::QueryParser,
schema::{Schema, STRING, TEXT},
DocAddress, Document, Score, TantivyDocument,
};
use tokio_util::io::ReaderStream;
use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
html::{
self, about_editor, admin_page, draft_editor, homepage_editor, search_page,
search_page_no_query,
},
BlogState,
};
use super::middleware::logged_in;
pub(super) async fn make_router(state: BlogState) -> Router {
#[cfg(debug_assertions)]
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
// axum logs rejections from built-in extractors with the `axum::rejection`
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
format!(
"{}=debug,tower_http=debug,axum::rejection=trace",
env!("CARGO_CRATE_NAME")
)
.into()
}),
)
.with(tracing_subscriber::fmt::layer())
.init();
let router = Router::new()
.merge(protected_routes(state.clone()).await)
.merge(blog(State(state.clone())))
.route("/assets/*asset", get(asset).with_state(state.clone()))
.nest("/api", api(state.clone()))
.nest("/login", login(State(state.clone())))
.route("/", get(pages).with_state(state.clone()))
.route("/*path", get(pages).with_state(state.clone()))
.route("/search", get(search_empty))
.route("/search/*query", get(search).with_state(state.clone()))
.fallback(get(handle_404))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(250 * 1024 * 1024));
#[cfg(debug_assertions)]
let router = router.layer(TraceLayer::new_for_http());
router
}
async fn protected_routes(state: BlogState) -> Router {
Router::new()
.route("/admin", get(admin).with_state(state.clone()))
.route("/editor/:id", get(editor).with_state(state.clone()))
.route(
"/editor/uploadFile",
post(editor_upload_file).with_state(state.clone()),
)
.route(
"/editor/fetchUrl",
post(editor_fetch_url).with_state(state.clone()),
)
.route_layer(from_fn_with_state(state.clone(), logged_in))
}
async fn pages(
path: Result<Path<String>, PathRejection>,
State(state): State<BlogState>,
) -> Response {
match path {
Ok(path) => match state.db().get_page(path.0).await {
Ok(page) => Html(page).into_response(),
Err(_) => four_oh_four(),
},
Err(error) => match error {
PathRejection::FailedToDeserializePathParams(_) => {
match state.db().get_page("/").await {
Ok(page) => Html(page).into_response(),
Err(_) => four_oh_four(),
}
}
_ => four_oh_four(),
},
}
}
pub(super) fn blog(State(state): State<BlogState>) -> Router {
Router::new()
.route("/blog", get(blog_root))
.route("/blog/*path", get(blog_post))
.with_state(state)
}
async fn blog_root(State(state): State<BlogState>) -> Response {
let db = state.db();
let page = html::blog_roll(db).await;
Html(page).into_response()
}
async fn blog_post(Path(id): Path<String>, State(state): State<BlogState>) -> Response {
let db = state.db();
match db.get_post(id).await {
Ok(post) => {
let html = html::blog_page(post.id, db).await;
Html(html).into_response()
}
Err(_) => four_oh_four(),
}
}
pub(crate) fn four_oh_four() -> Response {
(
StatusCode::NOT_FOUND,
Html(html::make_404("Page not found!")),
)
.into_response()
}
pub(crate) async fn editor(
jar: PrivateCookieJar,
Path(id): Path<String>,
State(state): State<BlogState>,
) -> Response {
let db = state.db();
match id.as_str() {
"new" => match db.add_draft("Untitled", "", "").await {
Ok(draft) => {
let mut headers = HeaderMap::new();
headers.insert(
LOCATION,
HeaderValue::from_str(&format!("/editor/{}", draft.id)).unwrap(),
);
(StatusCode::FOUND, headers).into_response()
}
Err(_) => {
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
(StatusCode::FOUND, headers).into_response()
}
},
"about" => {
let (_id, user) = match check_id(jar, db).await {
Ok(info) => info,
Err(response) => return response,
};
let about_content = db.get_user_info(user).await.unwrap().about;
let page = about_editor(&about_content);
Html(page).into_response()
}
"home" => {
let (_id, user) = match check_id(jar, db).await {
Ok(info) => info,
Err(response) => return response,
};
let home_content = db.get_user_info(user).await.unwrap().home;
let page = homepage_editor(&home_content);
Html(page).into_response()
}
_ => {
let draft = match db.edit_post(id).await {
Ok(draft) => draft,
Err(_) => {
let mut headers = HeaderMap::new();
headers.insert(LOCATION, HeaderValue::from_str("/editor/new").unwrap());
return (StatusCode::FOUND, headers).into_response();
}
};
let page = draft_editor(&draft.content, &draft.id.to_string());
Html(page).into_response()
}
}
}
#[derive(Serialize)]
struct UploadResult {
success: usize,
file: Option<UrlUpload>,
}
impl UploadResult {
fn success(url: String) -> Self {
Self {
success: 1,
file: Some(UrlUpload { url }),
}
}
fn failure() -> Self {
Self {
success: 0,
file: None,
}
}
}
pub(crate) async fn editor_upload_file(
State(state): State<BlogState>,
mut fields: Multipart,
) -> Response {
let db = state.db();
let field = match fields.next_field().await {
Ok(option) => match option {
Some(field) => field,
None => {
return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response()
}
},
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
};
let name = match field.file_name() {
Some(name) => name.to_string(),
None => "".to_string(),
};
let data = match field.bytes().await {
Ok(data) => data,
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
};
match db.add_asset(name, data).await {
Ok(asset) => {
let slug = ["/assets/", &asset.slug].concat();
Json(UploadResult::success(slug)).into_response()
}
Err(_error) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(UploadResult::failure()),
)
.into_response(),
}
}
#[derive(Deserialize, Serialize)]
pub struct UrlUpload {
url: String,
}
pub(crate) async fn editor_fetch_url(
// jar: PrivateCookieJar,
State(state): State<BlogState>,
Json(UrlUpload { url: _ }): Json<UrlUpload>,
) -> Response {
let _db = state.db();
(StatusCode::INTERNAL_SERVER_ERROR, Json(r#"{"success": 0}"#)).into_response()
}
async fn asset(Path(slug): Path<String>, State(state): State<BlogState>) -> Response {
let db = state.db();
match db.get_asset(slug).await {
Ok(asset) => {
async {
// i still don't quite know how to do async rust
let stream = ReaderStream::new(Cursor::new(asset.data));
let body = Body::from_stream(stream);
let mut headers = HeaderMap::new();
headers.append(CONTENT_TYPE, HeaderValue::from_str(&asset.mime).unwrap());
(headers, body).into_response()
}
.await
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn handle_404() -> Response {
four_oh_four()
}
async fn admin(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
let db = state.db();
// this can be unwrapped because our login middleware
// ensures the user has a valid id
let session_id = jar
.get("id")
.unwrap()
.value_trimmed()
.parse()
.unwrap_or_default();
let page = admin_page(session_id, db).await;
let mut headers = HeaderMap::new();
headers.append(
CACHE_CONTROL,
HeaderValue::from_str("no-cache, no-store, must-revalidate").unwrap(),
);
headers.append(PRAGMA, HeaderValue::from_str("no-cache").unwrap());
headers.append(EXPIRES, HeaderValue::from_str("0").unwrap());
(headers, Html(page)).into_response()
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub(crate) struct SearchResponse(Vec<SearchResponseEntry>);
impl SearchResponse {
pub(crate) fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl IntoIterator for SearchResponse {
type IntoIter = <Vec<SearchResponseEntry> as IntoIterator>::IntoIter;
type Item = SearchResponseEntry;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct SearchResponseEntry {
// idk why tantivy makes these fields sequences
body: Vec<String>,
pub(crate) id: Vec<String>,
title: Vec<String>,
}
async fn search_empty() -> Response {
Html(search_page_no_query()).into_response()
}
async fn search(Path(query): Path<String>, State(state): State<BlogState>) -> Response {
let query = query.split("-").collect::<Vec<&str>>().join(" ");
let closure_query = query.clone();
let closure_state = state.clone();
let response = tokio::task::spawn_blocking(move || {
let index = closure_state.index();
let reader = index.reader().unwrap();
let searcher = reader.searcher();
let mut schema_builder = Schema::builder();
let (id, title, body) = (
schema_builder.add_text_field("id", STRING),
schema_builder.add_text_field("title", TEXT),
schema_builder.add_text_field("body", TEXT),
);
let schema = schema_builder.build();
let query_parser = QueryParser::for_index(index, vec![id, title, body]);
let query = query_parser.parse_query(&closure_query).unwrap();
let top_docs: Vec<(Score, DocAddress)> =
searcher.search(&query, &TopDocs::with_limit(100)).unwrap();
let mut json = String::new();
json.push('[');
let mut docs_iter = top_docs.iter().peekable();
while let Some((_score, doc_address)) = docs_iter.next() {
let retrieved = searcher
.doc::<TantivyDocument>(*doc_address)
.unwrap_or_default();
json.push_str(&retrieved.to_json(&schema));
if docs_iter.peek().is_some() {
json.push(',');
}
}
json.push(']');
// TODO: Deserialize directly to SearchResponseEntry
let result = serde_json::from_str::<SearchResponse>(&json);
result.unwrap_or_default()
})
.await
.unwrap_or_default();
let db = state.db();
let html = search_page(query, response, db).await;
Html(html).into_response()
}

View File

@ -0,0 +1,7 @@
<li class="widget-entry">
<a class="entry-content" href="">
<h2></h2>
<p></p>
</a>
<input type="checkbox" name="item" value="">
</li>

View File

@ -0,0 +1,14 @@
<div class="admin-widget admin-widget-drafts">
<div class="widget-header">
<h1>Drafts</h1>
<a class="button" href="/editor/new">New Draft</a>
</div>
<form method="post">
<div class="form-actions">
<button type="submit" class="form-action" formaction="/api/drafts/delete">Delete drafts</button>
<button type="submit" class="form-action" formaction="/api/drafts/publish">Publish drafts</button>
</div>
<ul tabindex="-1">
</ul>
</form>
</div>

View File

@ -0,0 +1,8 @@
<li class="widget-entry">
<a class="entry-content" href="">
<time></time>
<h2></h2>
<p></p>
</a>
<input type="checkbox" name="item" value="">
</li>

View File

@ -0,0 +1,15 @@
<div class="admin-widget admin-widget-posts">
<div class="widget-header">
<h1>Published Posts</h1>
</div>
<form method="post">
<div class="form-actions">
<button class="form-action" type="submit" formaction="/api/posts/delete">Delete posts
</button>
<button class=" form-action" type="submit" formaction="/api/posts/unpublish">Unpublish Posts
</button>
</div>
<ul tabindex="-1">
</ul>
</form>
</div>

View File

@ -0,0 +1,16 @@
<div class="admin-widget admin-widget-user">
<form action="#user-info" method="get"><button>Update User Info</button></form>
<a class="button" href="/editor/home">Update Home Page</a>
<a class="button" href="/editor/about">Update About Page</a>
<form action="/api/auth/logout" method="post"><button type="submit">Logout</button></form>
<div id="user-info" class="admin-widget-user">
<form action="#" method="get"><button aria-label="close"></button></form>
<form action="/api/user/update" method="post" class="user-info-form">
<input type="text" name="username" placeholder="New Username" autocomplete="off" aria-label="New Username"
required />
<input type="password" name="password" placeholder="New Password" autocomplete="new-password"
aria-label="New Password" required />
<button type="submit">Update</button>
</form>
</div>
</div>

View File

@ -0,0 +1,5 @@
<admin-widget type="user"></admin-widget>
<div class="blog-admin">
<admin-widget type="drafts"></admin-widget>
<admin-widget type="posts"></admin-widget>
</div>

View File

@ -0,0 +1 @@
<time></time>

View File

@ -0,0 +1,10 @@
<li class="blog-roll-entry">
<a href="">
<img src="" />
<div class="entry-content">
<time></time>
<h2></h2>
<p></p>
</div>
</a>
</li>

View File

@ -0,0 +1,2 @@
<h1>Blog</h1>
<ul class="blog-roll"></ul>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/css/style.css" />
</head>
<body>
<site-header></site-header>
<main>
</main>
</body>
</html>

View File

@ -0,0 +1,4 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link href="/assets/css/style.css" rel="stylesheet">

View File

@ -0,0 +1,5 @@
<meta property="og:url" content="" />
<meta property="og:type" content="website" />
<meta property="og:title" content="" />
<meta property="og:description" content="" />
<meta property="og:image" content="" />

View File

@ -0,0 +1,3 @@
<div class="login-status">
<span>Incorrect username or password. Please try again.</span>
</div>

View File

@ -0,0 +1,23 @@
<div></div>
<main>
<div class="login-area">
<h1>Log In</h1>
<login-status></login-status>
<form method="POST" action="/api/auth/login">
<label>
Username
<input type="text" name="username" autocomplete="off" required>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<label>
<input type="checkbox" name="remember">
Remember me
</label>
<input type="hidden" name="next" value="">
<button type="submit">Login</button>
</form>
</div>
</main>

View File

@ -0,0 +1,7 @@
<main>
<div id="editorjs"></div>
</main>
<script id="editor"></script>
<script src="/assets/js/editor.js"></script>

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<site-header></site-header>
<main>
<h1></h1>
<ul></ul>
</main>
</body>
</html>

View File

@ -0,0 +1,19 @@
<header>
<a class="animated-link" href="/">
<h1>Evie Ippolito</h1>
</a>
<nav>
<a href="/blog" class="animated-link">blog</a>
<a href="/about" class="animated-link">about</a>
<form action="/api/search" method="get" role="search">
<input type="search" name="query" tabindex="0" autocomplete="off" autocapitalize="none" spellcheck="false"
required dir="auto" />
<button type="submit" aria-label="search" tabindex="-1">
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" height="1.5rem" width="1.5rem">
<path
d="M8.5 14a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Zm4.936-1.27 4.418 4.416-.708.708-4.417-4.418a6.5 6.5 0 1 1 .707-.707Z" />
</svg>
</button>
</form>
</nav>
</header>