Compare commits
26 Commits
6286352b5e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5848b7360c | |||
| d0a4b0f6a6 | |||
| 59a6205a1e | |||
| 4bfce7c928 | |||
| d95d764c4a | |||
| ab494f8381 | |||
| 35331ef076 | |||
| d7e4f91740 | |||
| a477b43f1c | |||
| 450c6d137b | |||
| f7b850d62a | |||
| cb6e666a6e | |||
| 892b2082c1 | |||
| 2740bd8c6f | |||
| 66dcbdc93a | |||
| 46c1edafa0 | |||
| 1789cb261f | |||
| d6f16fe65e | |||
| 725a77318a | |||
| 244cddd966 | |||
| 6e9c280542 | |||
| 8c5d5553c5 | |||
| ff12f2c7b4 | |||
| 114dc25235 | |||
| 33094f897d | |||
| cc5af850ce |
@@ -3,3 +3,4 @@
|
||||
.env
|
||||
*.db*
|
||||
key
|
||||
.DS_Store
|
||||
|
||||
Generated
+394
-361
File diff suppressed because it is too large
Load Diff
+6
-50
@@ -1,51 +1,7 @@
|
||||
[package]
|
||||
name = "evie"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# [package]
|
||||
# name = "evie"
|
||||
# version = "0.1.0"
|
||||
# edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
blogdb = { git = "https://git.augustkline.com/august/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"
|
||||
constcat = "0.5.1"
|
||||
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"
|
||||
[workspace]
|
||||
members = ["server", "blogdb"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
.DS_Store
|
||||
|
||||
Generated
+1704
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
about TEXT,
|
||||
home TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex (randomblob (16)))),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date TEXT,
|
||||
tags TEXT,
|
||||
content TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drafts (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex (randomblob (16)))),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
tags TEXT,
|
||||
content TEXT
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
slug STRING PRIMARY KEY UNIQUE NOT NULL,
|
||||
content STRING NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
user STRING NOT NULL,
|
||||
expires TEXT
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::{fmt::Debug, path::PathBuf, str::FromStr};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use infer::Type;
|
||||
use mime_guess::Mime;
|
||||
use rand::random;
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
use crate::BlogDb;
|
||||
|
||||
#[derive(Clone, FromRow)]
|
||||
pub struct Asset {
|
||||
pub slug: String,
|
||||
pub mime: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Debug for Asset {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Asset(slug: {}, mime: {})", self.slug, self.mime)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPORTED_MIME_TYPES: &[&str] = &["image/jpeg", "font/woff", "font/woff2"];
|
||||
|
||||
impl BlogDb {
|
||||
/// Add an asset with the given name and data.
|
||||
///
|
||||
/// If the asset shares a name with another asset in the parent path, the name will be prefixed with a unique id. Use the returned [`Asset`] name to access this asset in the future.
|
||||
pub async fn add_asset<S, D>(&self, slug: S, data: D) -> anyhow::Result<Asset>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
D: Into<Vec<u8>>,
|
||||
{
|
||||
let data = data.into();
|
||||
|
||||
let slug = PathBuf::from(slug.as_ref());
|
||||
let asset_ext: &str;
|
||||
let mime = match slug.extension() {
|
||||
Some(_) => {
|
||||
asset_ext = "";
|
||||
match mime_guess::from_path(&slug).first() {
|
||||
Some(mime) => &mime.to_string(),
|
||||
None => match infer::get(&data) {
|
||||
Some(mime) => mime.mime_type(),
|
||||
None => {
|
||||
return Err(anyhow::Error::msg(
|
||||
"Couldn't get the mime type of the provided asset",
|
||||
))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
None => match infer::get(&data) {
|
||||
Some(mime) => {
|
||||
asset_ext = mime.extension();
|
||||
mime.mime_type()
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow::Error::msg(
|
||||
"Couldn't get the mime type of the provided asset",
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut asset_name = slug
|
||||
.parent()
|
||||
.map(|parent| parent.to_str().unwrap())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
asset_name.push_str(&random::<i64>().to_string());
|
||||
if asset_ext.is_empty() {
|
||||
asset_name.push_str(&format!("-{}", slug.file_name().unwrap().to_str().unwrap()));
|
||||
} else {
|
||||
asset_name.push_str(&format!(
|
||||
"-{}.{asset_ext}",
|
||||
slug.file_stem().unwrap().to_str().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
let asset: Asset =
|
||||
sqlx::query_as("INSERT INTO assets (slug, mime, data) VALUES (?,?,?) RETURNING *")
|
||||
.bind(&asset_name)
|
||||
.bind(&mime)
|
||||
.bind(&data)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
Ok(asset)
|
||||
}
|
||||
|
||||
async fn asset_path_collides<S>(&self, slug: S) -> bool
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.get_asset(slug).await.is_ok()
|
||||
}
|
||||
|
||||
async fn mime_is_supported(&self, r#type: &Type) -> bool {
|
||||
SUPPORTED_MIME_TYPES.contains(&r#type.mime_type())
|
||||
}
|
||||
|
||||
/// Adds a collection of asset tuples to the db
|
||||
pub async fn add_assets<A, S, D>(&self, assets: A) -> anyhow::Result<()>
|
||||
where
|
||||
A: IntoIterator<Item = (S, D)>,
|
||||
S: AsRef<str>,
|
||||
D: Into<Vec<u8>>,
|
||||
{
|
||||
let assets = assets.into_iter();
|
||||
for asset in assets.into_iter() {
|
||||
let slug = asset.0;
|
||||
let data = asset.1.into();
|
||||
|
||||
let path = PathBuf::from(slug.as_ref());
|
||||
let mime = match path.extension() {
|
||||
Some(ext) => match ext.to_str() {
|
||||
Some(str) => match mime_guess::from_ext(str).first() {
|
||||
Some(mime) => mime.to_string(),
|
||||
None => match infer::get(&data) {
|
||||
Some(ext) => ext.mime_type().to_string(),
|
||||
None => "text/plain".to_string(),
|
||||
},
|
||||
},
|
||||
None => match infer::get(&data) {
|
||||
Some(ext) => ext.mime_type().to_string(),
|
||||
None => "text/plain".to_string(),
|
||||
},
|
||||
},
|
||||
None => match infer::get(&data) {
|
||||
Some(ext) => ext.mime_type().to_string(),
|
||||
None => "text/plain".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result =
|
||||
sqlx::query("REPLACE INTO assets (slug, mime, data) VALUES (?,?,?) RETURNING *")
|
||||
.bind(slug.as_ref())
|
||||
.bind(mime)
|
||||
.bind(data)
|
||||
.execute(&self.db)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get asset by slug
|
||||
pub async fn get_asset<S>(&self, slug: S) -> anyhow::Result<Asset>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let asset = sqlx::query_as("SELECT * FROM assets WHERE slug=?")
|
||||
.bind(slug.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await;
|
||||
// let asset: Asset = sqlx::query_as("SELECT * FROM assets WHERE slug=?")
|
||||
// .bind(slug.as_ref())
|
||||
// .fetch_one(&self.db)
|
||||
// .await?;
|
||||
Ok(asset?)
|
||||
}
|
||||
|
||||
/// Get all assets
|
||||
pub async fn get_assets(&self) -> anyhow::Result<Vec<Asset>> {
|
||||
let assets: Vec<Asset> = sqlx::query_as("SELECT * FROM assets")
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
/// Delete asset by name
|
||||
pub async fn delete_asset<S>(&self, name: S) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
sqlx::query("DELETE FROM assets WHERE name=?")
|
||||
.bind(name.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::util::tests::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn assets(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, session_id) = get_init_db(pool).await?;
|
||||
let slug = "picture";
|
||||
// PNG magic numbers
|
||||
let data = &[0x89, 0x50, 0x4e, 0x47];
|
||||
let asset = db.add_asset(slug, data).await;
|
||||
assert!(asset.is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
use crate::{posts::Post, BlogDb};
|
||||
|
||||
#[derive(Clone, FromRow, Debug)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
// Valid markdown
|
||||
pub content: String,
|
||||
// Separated by commas, no whitespace
|
||||
pub tags: String,
|
||||
}
|
||||
|
||||
impl BlogDb {
|
||||
/// Create a draft from an existing post, returning a [Draft] either for the new draft or for an existing draft
|
||||
pub async fn edit_post<S>(&self, id: S) -> anyhow::Result<Draft>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let draft_exists = sqlx::query_as::<_, Draft>("SELECT * FROM drafts WHERE id=?")
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await;
|
||||
|
||||
if let Ok(draft) = draft_exists {
|
||||
Ok(draft)
|
||||
} else {
|
||||
let post_exists = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE id=?")
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await;
|
||||
if let Ok(post) = post_exists {
|
||||
let new_draft: Draft = sqlx::query_as("INSERT INTO drafts (id, title, tags, content) SELECT id, title, tags, content FROM posts WHERE id=? RETURNING *")
|
||||
.bind(post.id).fetch_one(&self.db).await?;
|
||||
Ok(new_draft)
|
||||
} else {
|
||||
Err(anyhow::Error::msg(
|
||||
"Tried to edit a post that doesn't exist",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get draft by id
|
||||
pub async fn get_draft<S>(&self, id: S) -> anyhow::Result<Draft>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let draft: Draft = sqlx::query_as("SELECT * FROM drafts WHERE id=?")
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
/// Delete draft by id
|
||||
pub async fn delete_draft<S>(&self, id: S) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
sqlx::query_as::<_, Draft>("DELETE FROM drafts WHERE id=? RETURNING *")
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all drafts
|
||||
pub async fn get_drafts(&self, session_id: i64) -> anyhow::Result<Vec<Draft>> {
|
||||
self.check_session(session_id).await?;
|
||||
let drafts: Vec<Draft> = sqlx::query_as("SELECT * FROM drafts")
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
/// Create a new draft with the given data. See [`edit_post`](Self::edit_post) to create a draft from an existing post
|
||||
pub async fn add_draft<S, T, R>(&self, title: S, tags: T, content: R) -> anyhow::Result<Draft>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
R: AsRef<str>,
|
||||
{
|
||||
let draft: Draft =
|
||||
sqlx::query_as("REPLACE INTO drafts (title, tags, content) VALUES (?,?,?) RETURNING *")
|
||||
.bind(title.as_ref())
|
||||
.bind(tags.as_ref())
|
||||
.bind(content.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
pub async fn update_draft_title<S, T>(&self, id: S, title: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
sqlx::query("UPDATE drafts SET (title) = ? WHERE id=?")
|
||||
.bind(title.as_ref())
|
||||
.bind(id.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_draft_tags<S, T>(&self, id: S, tags: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
sqlx::query("UPDATE drafts SET (tags) = ? WHERE id=?")
|
||||
.bind(tags.as_ref())
|
||||
.bind(id.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_draft_content<S, T>(&self, id: S, content: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
sqlx::query("UPDATE drafts SET (content) = ? WHERE id=?")
|
||||
.bind(content.as_ref())
|
||||
.bind(id.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish draft by id, creating a new post if one with the id doesn't already exist, and updating the post if it does.
|
||||
///
|
||||
/// Note that if a new post is created its id may be incremented, making the id passed into this function invalid.
|
||||
/// Always use the returned [`Post`] id for future actions.
|
||||
pub async fn publish_draft<S>(&self, id: S) -> anyhow::Result<Post>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let update = sqlx::query_as::<_, Post>(
|
||||
"UPDATE posts
|
||||
SET (title, tags, content) = (SELECT title, tags, content FROM drafts WHERE id=?)
|
||||
WHERE id=?
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await;
|
||||
|
||||
if let Ok(post) = update {
|
||||
self.delete_draft(id).await?;
|
||||
Ok(post)
|
||||
} else {
|
||||
let new_post: Post = sqlx::query_as(
|
||||
"INSERT INTO posts (title, date, tags, content)
|
||||
SELECT title, DATE('now', 'localtime'), tags, content FROM drafts WHERE id=?
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(id.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
self.delete_draft(id).await?;
|
||||
Ok(new_post)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::util::tests::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn drafts(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, session_id, post) = init_and_add_post(pool).await?;
|
||||
|
||||
// Test draft that updates existing post
|
||||
|
||||
let id = post.id;
|
||||
db.edit_post(id).await?;
|
||||
|
||||
let title = "this is an updated title";
|
||||
let content = "this is some updated content";
|
||||
let tags = "updated";
|
||||
|
||||
db.update_draft_title(id, title).await?;
|
||||
db.update_draft_content(id, content).await?;
|
||||
db.update_draft_tags(id, tags).await?;
|
||||
db.publish_draft(id).await?;
|
||||
assert!(db.get_draft(id).await.is_err()); // Published drafts should be deleted
|
||||
|
||||
let post = db.get_post(id).await?;
|
||||
assert_eq!(post.title, title.to_string());
|
||||
assert_eq!(post.content, content.to_string());
|
||||
assert_eq!(post.tags, tags.to_string());
|
||||
|
||||
// Test new draft
|
||||
|
||||
let title = "this is a new title";
|
||||
let content = "this is new content";
|
||||
let tags = "new";
|
||||
|
||||
let draft = db.add_draft(title, tags, content).await?;
|
||||
assert!(db.get_draft(draft.id).await.is_ok());
|
||||
|
||||
let post = db.publish_draft(draft.id).await?;
|
||||
assert!(db.get_post(post.id).await.is_ok());
|
||||
assert_eq!(post.title, title.to_string());
|
||||
assert_eq!(post.content, content.to_string());
|
||||
assert_eq!(post.tags, tags.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use std::path::StripPrefixError;
|
||||
|
||||
use crate::BlogDb;
|
||||
|
||||
impl BlogDb {
|
||||
// pub async fn cache_post<C>(&self, id: i64, cacher: C) -> anyhow::Result<()>
|
||||
// where
|
||||
// C: Cacher<Post> + Send + 'static,
|
||||
// {
|
||||
// let post = self.get_post(id).await?;
|
||||
// let cached_page =
|
||||
// task::spawn_blocking(move || -> anyhow::Result<String> { cacher.cache(post) })
|
||||
// .await??; // await?? me?? me await??
|
||||
// let slug = format!("blog/{id}");
|
||||
// sqlx::query("REPLACE INTO pages (slug, content) VALUES (?, ?)")
|
||||
// .bind(slug)
|
||||
// .bind(cached_page)
|
||||
// .execute(&self.memory)
|
||||
// .await?;
|
||||
// Ok(())
|
||||
// }
|
||||
//
|
||||
|
||||
pub async fn add_page<S, T>(&self, slug: S, content: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
let slug = prepare_slug(slug.as_ref()).await;
|
||||
sqlx::query("INSERT INTO pages (slug, content) VALUES (?, ?)")
|
||||
.bind(slug)
|
||||
.bind(content.as_ref())
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_page<S>(&self, slug: S) -> anyhow::Result<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let slug = prepare_slug_query(slug.as_ref()).await;
|
||||
let page: (String,) = sqlx::query_as("SELECT content FROM pages WHERE slug=?")
|
||||
.bind(slug)
|
||||
.fetch_one(&self.memory)
|
||||
.await?;
|
||||
Ok(page.0)
|
||||
}
|
||||
|
||||
pub async fn update_page<S, T>(&self, slug: S, content: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
let slug = prepare_slug(slug.as_ref()).await;
|
||||
sqlx::query("REPLACE INTO pages (slug, content) VALUES (?, ?)")
|
||||
.bind(slug)
|
||||
.bind(content.as_ref())
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_page<S>(&self, slug: S) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let slug = prepare_slug(slug.as_ref()).await;
|
||||
sqlx::query("DELETE FROM pages WHERE slug=?")
|
||||
.bind(slug)
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_pages<P, S, C>(&self, pages: P) -> anyhow::Result<()>
|
||||
where
|
||||
P: IntoIterator<Item = (S, C)>,
|
||||
S: AsRef<str>,
|
||||
C: AsRef<str>,
|
||||
{
|
||||
let pages = pages.into_iter();
|
||||
for page in pages.into_iter() {
|
||||
let slug = prepare_slug(page.0.as_ref()).await;
|
||||
let content = page.1.as_ref();
|
||||
|
||||
let result: (String, String) =
|
||||
sqlx::query_as("REPLACE INTO pages (slug, content) VALUES (?,?) RETURNING *")
|
||||
.bind(slug)
|
||||
.bind(content)
|
||||
.fetch_one(&self.memory)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_pages(&self) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let assets: Vec<(String, String)> = sqlx::query_as("SELECT * FROM assets")
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
Ok(assets)
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine multiple patterns that match a page (e.g. `/blog/index.html` and `/blog/`)
|
||||
async fn prepare_slug<'a>(mut slug: &'a str) -> &str {
|
||||
if slug.ends_with(".html") {
|
||||
slug = slug.strip_suffix(".html").unwrap()
|
||||
} else if slug.ends_with("/index.html") {
|
||||
slug = slug.strip_suffix("/index.html").unwrap()
|
||||
}
|
||||
if slug.ends_with("/") {
|
||||
slug = slug.strip_suffix("/").unwrap()
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
/// Combine multiple patterns that match a page (e.g. `/blog/index.html` and `/blog/`)
|
||||
async fn prepare_slug_query<'a>(mut slug: &'a str) -> &str {
|
||||
if slug.ends_with(".html") {
|
||||
slug = slug.strip_suffix(".html").unwrap()
|
||||
} else if slug.ends_with("/index.html") {
|
||||
slug = slug.strip_suffix("/index.html").unwrap()
|
||||
}
|
||||
if slug.ends_with("/") {
|
||||
slug = slug.strip_suffix("/").unwrap()
|
||||
}
|
||||
if slug == "" {
|
||||
slug = "index"
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::tests::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// struct PostCacher;
|
||||
|
||||
// impl Cacher<Post> for PostCacher {
|
||||
// fn cache(&self, cacheable: Post) -> anyhow::Result<String> {
|
||||
// Ok("cached page!".to_string())
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[sqlx::test]
|
||||
// async fn cache(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
// let (db, session_id, post) = init_and_add_post(pool).await?;
|
||||
// db.cache_post(post.id, PostCacher).await?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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(())
|
||||
}
|
||||
/// Delete post by id
|
||||
pub async fn set_post_date<S, T>(&self, id: S, date: T) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
sqlx::query("UPDATE posts SET date=? WHERE id=?")
|
||||
.bind(date.as_ref())
|
||||
.bind(id.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::util::tests::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn posts(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, session_id) = get_init_db(pool).await?;
|
||||
|
||||
let title = "ʕ·ᴥ·ʔ";
|
||||
let content = "ʕ·ᴥ·ʔ- hello there";
|
||||
let tags = "george";
|
||||
let post = db.add_post(title, tags, content).await?;
|
||||
|
||||
let id = post.id;
|
||||
|
||||
let post = db.get_post(id).await?;
|
||||
assert_eq!(post.title, title.to_string());
|
||||
assert_eq!(post.content, content.to_string());
|
||||
assert_eq!(post.tags, tags.to_string());
|
||||
|
||||
db.delete_post(id).await?;
|
||||
let deleted = db.get_post(id).await.is_err();
|
||||
assert!(deleted);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use sqlx::{prelude::FromRow, SqlitePool};
|
||||
|
||||
use crate::{assets::Asset, hash_value, verify_hashed_value, BlogDb};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct UserInfo {
|
||||
pub name: String,
|
||||
pub about: String,
|
||||
pub home: String,
|
||||
}
|
||||
|
||||
impl From<User> for UserInfo {
|
||||
fn from(value: User) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
about: value.about,
|
||||
home: value.home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, FromRow, Default)]
|
||||
struct User {
|
||||
name: String,
|
||||
password_hash: String,
|
||||
about: String, // this isn't great but these r the home and about page html
|
||||
home: String,
|
||||
}
|
||||
|
||||
impl Display for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"User(name: {}, password_hash: ###, homepage: {:?})",
|
||||
self.name, self.home
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlogDb {
|
||||
pub(super) async fn verify_password<S>(&self, user: S, password: String) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE name=?")
|
||||
.bind(user.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
let hash = user.password_hash;
|
||||
verify_hashed_value(password, hash).await
|
||||
}
|
||||
|
||||
pub(super) async fn add_initial_user<S>(
|
||||
db: &SqlitePool,
|
||||
username: S,
|
||||
password: String,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let password_hash = hash_value(password).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO users (name, password_hash) VALUES (?,?)")
|
||||
.bind(username.as_ref())
|
||||
.bind(password_hash)
|
||||
.execute(db)
|
||||
.await
|
||||
.with_context(|| "Something went wrong while executing sqlite query")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update password with new string, authenticated with current valid password hash
|
||||
pub async fn update_password<S>(
|
||||
&self,
|
||||
user: S,
|
||||
password: String,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
if user.as_ref() != &self.check_session(session_id).await? {
|
||||
return Err(anyhow::Error::msg(
|
||||
"Updating password, username doesn't match session username",
|
||||
));
|
||||
};
|
||||
|
||||
let hash = hash_value(password.clone()).await?;
|
||||
|
||||
sqlx::query("UPDATE users SET (password_hash) = ?")
|
||||
.bind(&hash)
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE sessions SET (password_hash) = ? WHERE user = ?")
|
||||
.bind(&hash)
|
||||
.bind(user.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user(&self) -> UserInfo {
|
||||
let user: UserInfo = sqlx::query_as::<_, User>("SELECT * FROM users")
|
||||
.fetch_one(&self.db)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
|
||||
user
|
||||
}
|
||||
|
||||
pub async fn get_user_info<S>(&self, user: S) -> anyhow::Result<UserInfo>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE name=?")
|
||||
.bind(user.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(user.into())
|
||||
}
|
||||
|
||||
pub async fn update_user_name<S, T>(
|
||||
&self,
|
||||
user: S,
|
||||
name: T,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<UserInfo>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
self.authorize_user(user.as_ref(), session_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"An unauthorized user tried to update user {}'s name",
|
||||
user.as_ref()
|
||||
)
|
||||
})?;
|
||||
|
||||
let info: UserInfo =
|
||||
sqlx::query_as::<_, User>("UPDATE users SET (name) = ? WHERE name=? RETURNING *")
|
||||
.bind(name.as_ref())
|
||||
.bind(user.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?
|
||||
.into();
|
||||
sqlx::query("UPDATE sessions SET (user) = ? WHERE user=?")
|
||||
.bind(&info.name)
|
||||
.bind(user.as_ref())
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn get_homepage(&self) -> String {
|
||||
let user: User = sqlx::query_as("SELECT * FROM users")
|
||||
.fetch_one(&self.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
user.home
|
||||
}
|
||||
|
||||
pub async fn get_about(&self) -> String {
|
||||
let user: User = sqlx::query_as("SELECT * FROM users")
|
||||
.fetch_one(&self.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
user.about
|
||||
}
|
||||
|
||||
pub async fn update_user_homepage<S, T>(
|
||||
&self,
|
||||
user: S,
|
||||
home: T,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
self.authorize_user(user.as_ref(), session_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"An unauthorized user tried to update user {}'s name",
|
||||
user.as_ref()
|
||||
)
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE users SET (home) = ? WHERE name=? RETURNING *")
|
||||
.bind(home.as_ref())
|
||||
.bind(user.as_ref())
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_about<S, T>(
|
||||
&self,
|
||||
user: S,
|
||||
about: T,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<UserInfo>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
self.authorize_user(user.as_ref(), session_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"An unauthorized user tried to update user {}'s bio",
|
||||
user.as_ref()
|
||||
)
|
||||
})?;
|
||||
let info =
|
||||
sqlx::query_as::<_, User>("UPDATE users SET (about) = ? WHERE name=? RETURNING *")
|
||||
.bind(about.as_ref())
|
||||
.bind(user.as_ref())
|
||||
.fetch_one(&self.db)
|
||||
.await?
|
||||
.into();
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
// /// Updates user profile pic with asset from database
|
||||
// pub async fn update_user_profile_pic<S, T>(
|
||||
// &self,
|
||||
// user: S,
|
||||
// profile_pic_name: T,
|
||||
// session_id: i64,
|
||||
// ) -> anyhow::Result<UserInfo>
|
||||
// where
|
||||
// S: AsRef<str>,
|
||||
// T: AsRef<str>,
|
||||
// {
|
||||
// self.authorize_user(user.as_ref(), session_id)
|
||||
// .await
|
||||
// .map_err(|_| {
|
||||
// anyhow!(
|
||||
// "An unauthorized user tried to update user {}'s profile pic",
|
||||
// user.as_ref()
|
||||
// )
|
||||
// })?;
|
||||
// let info = sqlx::query_as::<_, User>(
|
||||
// "UPDATE users SET (profile_pic) = ? WHERE name=? RETURNING *",
|
||||
// )
|
||||
// .bind(profile_pic_name.as_ref())
|
||||
// .bind(user.as_ref())
|
||||
// .fetch_one(&self.db)
|
||||
// .await?
|
||||
// .into();
|
||||
// Ok(info)
|
||||
// }
|
||||
|
||||
// /// Replaces user profile pic with new photo, saving it to the database as an asset
|
||||
// pub async fn replace_user_profile_pic<S, D>(
|
||||
// &self,
|
||||
// user: S,
|
||||
// profile_pic_data: D,
|
||||
// session_id: i64,
|
||||
// ) -> anyhow::Result<(Asset, UserInfo)>
|
||||
// where
|
||||
// S: AsRef<str>,
|
||||
// D: Into<Vec<u8>>,
|
||||
// {
|
||||
// self.authorize_user(user.as_ref(), session_id)
|
||||
// .await
|
||||
// .map_err(|_| {
|
||||
// anyhow!(
|
||||
// "An unauthorized user tried to replace user {}'s profile pic",
|
||||
// user.as_ref()
|
||||
// )
|
||||
// })?;
|
||||
|
||||
// let data = profile_pic_data.into();
|
||||
|
||||
// let user_name = self.get_user_info(user.as_ref()).await?.name;
|
||||
// let ext = infer::get(&data)
|
||||
// .ok_or(anyhow!("Couldn't get the profile pic filetype"))?
|
||||
// .extension();
|
||||
// let name = format!("{user_name}-profile.{ext}");
|
||||
|
||||
// let profile_pic = self.add_asset(name, data).await?;
|
||||
|
||||
// let updated_info: UserInfo = sqlx::query_as::<_, User>(
|
||||
// "UPDATE users SET (profile_pic) = ? WHERE name=? RETURNING *",
|
||||
// )
|
||||
// .bind(&profile_pic.slug)
|
||||
// .bind(user.as_ref())
|
||||
// .fetch_one(&self.db)
|
||||
// .await?
|
||||
// .into();
|
||||
|
||||
// Ok((profile_pic, updated_info))
|
||||
// }
|
||||
|
||||
/// Check if a given session id is valid, and if it is, get the user it's valid for
|
||||
pub async fn check_session<I>(&self, session_id: I) -> anyhow::Result<String>
|
||||
where
|
||||
I: Into<i64>,
|
||||
{
|
||||
let _ = sqlx::query("DELETE * FROM SESSIONS WHERE date<DATE('now')")
|
||||
.execute(&self.memory)
|
||||
.await;
|
||||
let (_, user_id): (i64, String) = sqlx::query_as("SELECT * FROM sessions WHERE id=?")
|
||||
.bind(session_id.into())
|
||||
.fetch_one(&self.memory)
|
||||
.await?;
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
/// Log the user in, returning a session id.
|
||||
pub async fn new_session<S, T>(
|
||||
&self,
|
||||
user: S,
|
||||
password: String,
|
||||
expires: T,
|
||||
) -> anyhow::Result<i64>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
self.verify_password(user.as_ref(), password).await?;
|
||||
|
||||
// ChaCha20Rng panics if it can't get secure entropy, which prolly won't happen but still
|
||||
// want to catch if it does
|
||||
let id = std::panic::catch_unwind(|| ChaCha20Rng::from_entropy().next_u64())
|
||||
.map_err(|_| anyhow!("Couldn't get secure entropy to generate session id"))?
|
||||
as i64;
|
||||
|
||||
sqlx::query("INSERT INTO sessions VALUES (?,?, DATE('now', ?))")
|
||||
.bind(id)
|
||||
.bind(user.as_ref())
|
||||
.bind(expires.as_ref())
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// /// Get a session for the server, returning a session id.
|
||||
// pub async fn get_server_session(&self) -> anyhow::Result<i64> {
|
||||
// // ChaCha20Rng panics if it can't get secure entropy, which prolly won't happen but still
|
||||
// // want to catch if it does
|
||||
// let id = std::panic::catch_unwind(|| ChaCha20Rng::from_entropy().next_u64())
|
||||
// .map_err(|_| anyhow!("Couldn't get secure entropy to generate session id"))?
|
||||
// as i64;
|
||||
|
||||
// let user: String = random::<u64>().to_string();
|
||||
|
||||
// sqlx::query("INSERT INTO sessions VALUES (?,?)")
|
||||
// .bind(id)
|
||||
// .bind(user)
|
||||
// .execute(&self.memory)
|
||||
// .await?;
|
||||
// Ok(id)
|
||||
// }
|
||||
|
||||
/// End the user session, call this when logging out the user
|
||||
pub async fn end_session<I>(&self, id: I) -> anyhow::Result<()>
|
||||
where
|
||||
I: Into<i64>,
|
||||
{
|
||||
sqlx::query("DELETE FROM sessions WHERE id=?")
|
||||
.bind(id.into())
|
||||
.execute(&self.memory)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a session is valid for a given user
|
||||
pub(super) async fn authorize_user<S, I>(&self, user: S, session_id: I) -> anyhow::Result<()>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
I: Into<i64> + Copy,
|
||||
{
|
||||
let session_user = self.check_session(session_id).await?;
|
||||
// this could be a match or if/else statement but i think it's a funny one-liner
|
||||
(user.as_ref() == session_user).then_some(()).ok_or(anyhow!(
|
||||
"Session {} is not valid for user {}",
|
||||
session_id.into(),
|
||||
user.as_ref()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::util::tests::*;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_info(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, session_id) = get_init_db(pool).await?;
|
||||
|
||||
// Getting info
|
||||
|
||||
let expected_info: UserInfo = UserInfo {
|
||||
name: "evie".to_string(),
|
||||
about: "".to_string(),
|
||||
home: None,
|
||||
};
|
||||
assert_eq!(db.get_user_info("evie").await?, expected_info);
|
||||
|
||||
// Updating info
|
||||
|
||||
let user = db.get_user_info("evie").await?;
|
||||
|
||||
let new_name = "august";
|
||||
let new_bio = "loves george";
|
||||
|
||||
db.update_user_name(&user.name, new_name, session_id)
|
||||
.await?;
|
||||
db.update_user_about(new_name, new_bio, session_id).await?;
|
||||
|
||||
// PNG magic numbers
|
||||
let profile_pic_data = &[0x89, 0x50, 0x4e, 0x47];
|
||||
|
||||
let profile_pic = db.add_asset("profile.png", profile_pic_data).await?;
|
||||
|
||||
let updated_info = db
|
||||
.update_user_profile_pic(new_name, &profile_pic.slug, session_id)
|
||||
.await?;
|
||||
assert_eq!(updated_info.profile_pic, Some(profile_pic.slug));
|
||||
|
||||
db.replace_user_profile_pic(new_name, profile_pic_data, session_id)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sessions(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, _) = get_init_db(pool).await?;
|
||||
|
||||
let username = "evie";
|
||||
let password = "hunter2".to_string();
|
||||
let id = db.new_session(username, password, "+5 minutes").await?;
|
||||
|
||||
let session_result = db.check_session(id).await;
|
||||
assert!(session_result.is_ok());
|
||||
|
||||
let logout = db.end_session(id).await;
|
||||
assert!(logout.is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_password(pool: SqlitePool) -> anyhow::Result<()> {
|
||||
let (db, session_id) = get_init_db(pool).await?;
|
||||
|
||||
let update = db
|
||||
.update_password(
|
||||
"evie",
|
||||
"hunter2".to_string(),
|
||||
"password".to_string(),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(update.is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use anyhow::{anyhow, Context};
|
||||
use argon2::{password_hash::SaltString, Argon2};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
use argon2::{PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::task;
|
||||
|
||||
pub(super) async fn verify_hashed_value(value: String, hash: String) -> anyhow::Result<()> {
|
||||
task::spawn_blocking(move || {
|
||||
let hash = PasswordHash::new(&hash)?;
|
||||
Argon2::default()
|
||||
.verify_password(value.as_bytes(), &hash)
|
||||
.map_err(|_| anyhow!("Couldn't verify hashed value"))
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn hash_value(value: String) -> anyhow::Result<String> {
|
||||
task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
Ok(argon2.hash_password(value.as_bytes(), &salt)?.to_string())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use crate::*;
|
||||
use posts::Post;
|
||||
use sqlx::{ConnectOptions, SqlitePool};
|
||||
|
||||
pub(crate) async fn get_init_db(pool: SqlitePool) -> anyhow::Result<(BlogDb, i64)> {
|
||||
let user = "evie";
|
||||
let password = "hunter2".to_string();
|
||||
let db_url = pool.connect_options().to_url_lossy().to_string();
|
||||
let db = BlogDb::new(user, password.clone(), db_url).await?;
|
||||
let session_id = db.new_session(user, password, "+1 year").await?;
|
||||
Ok((db, session_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn init_and_add_post(pool: SqlitePool) -> anyhow::Result<(BlogDb, i64, Post)> {
|
||||
let (db, session_id) = get_init_db(pool).await?;
|
||||
let post = db.add_post("hello", "", "").await?;
|
||||
let resulting_post = db.get_post(post.id).await?;
|
||||
|
||||
assert_eq!(resulting_post.title, "hello");
|
||||
assert_eq!(resulting_post.tags, "");
|
||||
assert_eq!(resulting_post.content, "");
|
||||
|
||||
Ok((db, session_id, post))
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
# this only works on macos for now, cross compilation toolchain from https://github.com/messense/homebrew-macos-cross-toolchains
|
||||
|
||||
set -e;
|
||||
|
||||
cargo update;
|
||||
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc cargo build --release --target x86_64-unknown-linux-gnu;
|
||||
DOMAIN=evieippolito.com CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc cargo build --release --target x86_64-unknown-linux-gnu;
|
||||
|
||||
scp target/x86_64-unknown-linux-gnu/release/evie kline@augustkline.com:/home/kline/;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "evie"
|
||||
path = "./src/main.rs"
|
||||
|
||||
[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"
|
||||
chrono = { version = "0.4.39", features = ["alloc"] }
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy = "0.15.7"
|
||||
glob = "0.3.1"
|
||||
phf = "0.11.2"
|
||||
phf_codegen = "0.11.2"
|
||||
constcat = "0.5.1"
|
||||
@@ -17,7 +17,7 @@ fn main() {
|
||||
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 mut outfile = 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();
|
||||
@@ -41,14 +41,30 @@ fn main() {
|
||||
let mut content = "r####\"".to_string();
|
||||
file.read_to_string(&mut content).unwrap();
|
||||
content.push_str("\"####");
|
||||
println!("cargo:warning={}\n{}", &slug, &content);
|
||||
// println!("cargo:warning={}\n{}", &slug, &content);
|
||||
map.entry(slug, &content);
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
// this is all a mess cause i'm lazy lol
|
||||
let feed_template_path = [&template_dir, "/feed.xml"].concat();
|
||||
let mut feed_file = File::open(feed_template_path).unwrap();
|
||||
let mut content = "r####\"".to_string();
|
||||
feed_file.read_to_string(&mut content).unwrap();
|
||||
content.push_str("\"####");
|
||||
let feed_template = content;
|
||||
map.entry("feed".to_string(), &feed_template);
|
||||
let item_template_path = [&template_dir, "/item.xml"].concat();
|
||||
let mut item_file = File::open(item_template_path).unwrap();
|
||||
let mut content = "r####\"".to_string();
|
||||
item_file.read_to_string(&mut content).unwrap();
|
||||
content.push_str("\"####");
|
||||
let item_template = content;
|
||||
map.entry("item".to_string(), &item_template);
|
||||
|
||||
writeln!(
|
||||
&mut file,
|
||||
&mut outfile,
|
||||
"static TEMPLATES: phf::Map<&'static str, &'static str> = {};",
|
||||
map.build()
|
||||
)
|
||||
@@ -93,7 +109,7 @@ fn main() {
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.into(); // eek
|
||||
println!("cargo:warning={}{:?}", &slug, &path);
|
||||
// println!("cargo:warning={}{:?}", &slug, &path);
|
||||
writeln!(
|
||||
&mut file,
|
||||
r#"assets.push(("{slug}",include_bytes!("{}")));"#,
|
||||
@@ -6,6 +6,7 @@ main {
|
||||
gap: var(--default-padding);
|
||||
margin-block-end: 40svb;
|
||||
padding: 0;
|
||||
min-block-size: unset;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 60rem) {
|
||||
@@ -21,6 +22,10 @@ main {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.admin-widget:not(.admin-widget-user) {
|
||||
max-inline-size: calc(50% - (0.5 * var(--default-padding)));
|
||||
}
|
||||
|
||||
.admin-widget-user {
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
@@ -117,8 +122,8 @@ main {
|
||||
.blog-admin {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--default-padding);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--default-padding);
|
||||
box-sizing: border-box;
|
||||
min-block-size: 0;
|
||||
flex: 1;
|
||||
@@ -198,6 +203,7 @@ main {
|
||||
min-block-size: 0;
|
||||
border: var(--border);
|
||||
display: flex;
|
||||
max-inline-size: 100%;
|
||||
|
||||
&>*:not(form, a) {
|
||||
padding-inline: var(--default-padding);
|
||||
@@ -212,6 +218,7 @@ main {
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -239,10 +246,17 @@ main {
|
||||
a.entry-content {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
inline-size: 90%;
|
||||
max-inline-size: 90%;
|
||||
|
||||
transition: all 0.3s ease;
|
||||
|
||||
& :is(p, h2) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: translateX(1ch);
|
||||
@@ -8,6 +8,7 @@ h1 {
|
||||
|
||||
.blog-roll-entry {
|
||||
margin-block: 1rem;
|
||||
overflow: visible;
|
||||
|
||||
a {
|
||||
border: var(--border);
|
||||
@@ -21,7 +22,9 @@ h1 {
|
||||
|
||||
.entry-content {
|
||||
padding: var(--content-padding);
|
||||
transition: padding-inline-start 0.3s ease, box-shadow 0.3s ease;
|
||||
transition: padding 0.3s ease, box-shadow 0.3s ease;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
* {
|
||||
margin-block: 0;
|
||||
@@ -32,6 +35,7 @@ h1 {
|
||||
&:focus-visible {
|
||||
.entry-content {
|
||||
padding-inline-start: calc(var(--content-padding) * 2);
|
||||
padding-inline-end: 0;
|
||||
box-shadow: inset 3px 0px 0px var(--color-text);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ main {
|
||||
|
||||
@media all and (max-width: 650px) {
|
||||
.ce-toolbar__actions {
|
||||
|
||||
&>*:nth-child(1),
|
||||
&>*:nth-child(2) {
|
||||
border-radius: 0;
|
||||
@@ -192,6 +193,11 @@ main {
|
||||
background-color: var(--color-selection) !important;
|
||||
}
|
||||
|
||||
.cdx-quote__text {
|
||||
border-block-end: none !important;
|
||||
margin-block-end: 0 !important;
|
||||
}
|
||||
|
||||
.embed-tool__caption {
|
||||
display: none;
|
||||
}
|
||||
@@ -3,14 +3,9 @@ body {
|
||||
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;
|
||||
@@ -39,7 +34,6 @@ main {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* block-size: 100%; */
|
||||
max-block-size: 100%;
|
||||
|
||||
&>:first-child {
|
||||
@@ -71,3 +65,13 @@ form {
|
||||
max-inline-size: 40ch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 50rem) {
|
||||
body {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
--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. */
|
||||
@@ -71,10 +70,12 @@ input,
|
||||
label,
|
||||
a,
|
||||
blockquote,
|
||||
figcaption,
|
||||
aside,
|
||||
ol,
|
||||
ul {
|
||||
font-size: calc(var(--font-size) * 1.33);
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -87,26 +88,22 @@ h6 {
|
||||
}
|
||||
|
||||
aside,
|
||||
blockquote {
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
blockquote,
|
||||
aside {
|
||||
figure {
|
||||
padding: var(--default-padding) calc(var(--default-padding) * 2);
|
||||
border: var(--border);
|
||||
max-inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
&::before {
|
||||
content: '“';
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '”';
|
||||
font-weight: 600;
|
||||
figure {
|
||||
& figcaption {
|
||||
font-style: italic;
|
||||
margin-inline-start: var(--default-padding);
|
||||
margin-block-start: var(--default-padding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +116,29 @@ ul {
|
||||
a {
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
|
||||
&:not(:has(time)):is([href^="http"], [href^="mailto"]) {
|
||||
padding-inline-end: 0.9em;
|
||||
|
||||
&::after {
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-inline-start: -0.05em;
|
||||
margin-block-start: 0.23em;
|
||||
background-size: 100%;
|
||||
transform: scale(0.8);
|
||||
background-image: url("/assets/images/external.svg");
|
||||
}
|
||||
|
||||
&:is(:hover, :active, :focus-visible)::after {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -135,7 +155,7 @@ input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input:is([type="text"], [type="password"], [type="search"]) {
|
||||
input:is([type="text"], [type="password"], [type="search"], [type="email"], ) {
|
||||
padding: 0.5ch 1ch;
|
||||
|
||||
&:focus,
|
||||
@@ -215,6 +235,9 @@ button,
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.animated-link {
|
||||
background-size: 100% 0%;
|
||||
}
|
||||
|
||||
.animated-link-underline {
|
||||
background-size: 100% 1px;
|
||||
@@ -223,7 +246,6 @@ button,
|
||||
.animated-link,
|
||||
.animated-link-underline {
|
||||
text-decoration: unset;
|
||||
background-size: 100% 0%;
|
||||
background-position: left bottom;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@@ -267,7 +289,7 @@ header {
|
||||
background: var(--color-bg);
|
||||
|
||||
&>:first-child {
|
||||
margin: unset;
|
||||
margin-block-end: var(--default-padding);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -345,9 +367,66 @@ header {
|
||||
|
||||
}
|
||||
|
||||
iframe {
|
||||
max-inline-size: 100%;
|
||||
appearance: unset;
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-block-start: var(--border);
|
||||
margin-block-start: calc(var(--default-padding) * 6);
|
||||
display: flex;
|
||||
min-block-size: calc(var(--header-size) * 2.5);
|
||||
padding: calc(1.5 * var(--default-padding)) var(--default-padding);
|
||||
gap: calc(2 * var(--default-padding));
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
|
||||
&>* {
|
||||
max-inline-size: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& div {
|
||||
display: flex;
|
||||
max-inline-size: 100%;
|
||||
|
||||
&>* {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
min-inline-size: min-content;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
h1,
|
||||
ul {
|
||||
margin-block: 0 1rem;
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
input {
|
||||
margin-block: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
main {
|
||||
margin-block: var(--default-padding) 40svb;
|
||||
margin-inline: auto;
|
||||
min-block-size: 100svb;
|
||||
margin: var(--default-padding) auto;
|
||||
max-inline-size: min(60ch, 80%);
|
||||
padding-block: var(--default-padding);
|
||||
gap: var(--default-padding);
|
||||
@@ -414,12 +493,26 @@ main {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
&>:first-child {
|
||||
margin-block-end: unset;
|
||||
}
|
||||
|
||||
nav {
|
||||
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-direction: row;
|
||||
|
||||
&>* {
|
||||
max-inline-size: 40ch;
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
margin-block: var(--default-padding);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M 5 5 L 5 27 L 27 27 L 27 5 Z M 7 7 L 25 7 L 25 25 L 7 25 Z M 13 10 L 13 12 L 18.5625 12 L 9.28125 21.28125 L 10.71875 22.71875 L 20 13.4375 L 20 19 L 22 19 L 22 10 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,11 @@
|
||||
const editDateButton = document.getElementById("date-update");
|
||||
const postActions = document.getElementById("post-actions");
|
||||
const children = postActions.innerHTML;
|
||||
|
||||
dateInput = `<button class="form-action" formaction="/admin" formmethod="get" formnovalidate>✕</button><input class="form-action" type="date" name="date" placeholder="Enter Date" autocomplete="off" aria-label="New Date" required /> <button class="form-action" type="submit" formaction="/api/posts/date">Update Date</button>`;
|
||||
|
||||
editDateButton.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
console.log(ev, postActions, dateInput);
|
||||
postActions.innerHTML = dateInput;
|
||||
});
|
||||
@@ -1776,13 +1776,13 @@
|
||||
(this.settings = [
|
||||
{
|
||||
name: "unordered",
|
||||
label: this.api.i18n.t("Unordered"),
|
||||
label: this.api.i18n.t("Bulleted"),
|
||||
icon: n,
|
||||
default: t.defaultStyle === "unordered" || !1,
|
||||
},
|
||||
{
|
||||
name: "ordered",
|
||||
label: this.api.i18n.t("Ordered"),
|
||||
label: this.api.i18n.t("Numbered"),
|
||||
icon: l,
|
||||
default: t.defaultStyle === "ordered" || !0,
|
||||
},
|
||||
@@ -3239,16 +3239,6 @@
|
||||
static get sanitize() {
|
||||
return { text: { br: !0 }, caption: { br: !0 }, alignment: {} };
|
||||
}
|
||||
renderSettings() {
|
||||
const t = (n) => n && n[0].toUpperCase() + n.slice(1);
|
||||
return this.settings.map((n) => ({
|
||||
icon: n.icon,
|
||||
label: this.api.i18n.t(`Align ${t(n.name)}`),
|
||||
onActivate: () => this._toggleTune(n.name),
|
||||
isActive: this._data.alignment === n.name,
|
||||
closeOnActivate: !0,
|
||||
}));
|
||||
}
|
||||
_toggleTune(t) {
|
||||
(this._data.alignment = t), this._block.dispatchChange();
|
||||
}
|
||||
@@ -13662,7 +13652,7 @@ var editor = new EditorJS({
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
quotePlaceholder: "Enter a quote",
|
||||
captionPlaceholder: "Quote's author",
|
||||
captionPlaceholder: "Add author or a caption, leave blank to hide",
|
||||
},
|
||||
shortcut: "CMD+SHIFT+O",
|
||||
},
|
||||
@@ -1,3 +1,4 @@
|
||||
use chrono::prelude::*;
|
||||
use std::{env, vec};
|
||||
|
||||
use blogdb::posts::Post;
|
||||
@@ -35,7 +36,6 @@ pub(crate) fn make_page<S>(content: S, settings: PageSettings) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
for t in TEMPLATES.into_iter() {}
|
||||
rewrite_str(
|
||||
template!("default"),
|
||||
RewriteStrSettings {
|
||||
@@ -73,6 +73,12 @@ where
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("site-footer", |site_footer| {
|
||||
if settings.site_footer {
|
||||
site_footer.replace(template!("site-footer"), ContentType::Html);
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("br", |br| {
|
||||
br.remove();
|
||||
Ok(())
|
||||
@@ -188,7 +194,7 @@ where
|
||||
.unwrap_or_default();
|
||||
make_page(
|
||||
html,
|
||||
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true),
|
||||
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true, false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,7 +236,7 @@ pub(crate) fn login_status(next: &str, success: bool) -> String {
|
||||
.unwrap_or_default();
|
||||
make_page(
|
||||
html,
|
||||
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false),
|
||||
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false, false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,7 +244,7 @@ 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),
|
||||
PageSettings::new("Admin", Some(vec!["/assets/css/admin.css"]), true, false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,8 +295,7 @@ async fn admin_entries(entry_type: EntryType, session_id: i64, db: &BlogDb) -> S
|
||||
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 entries = db.get_posts().await.unwrap_or(vec![]);
|
||||
let mut entry_list_html = String::new();
|
||||
is_empty = entries.is_empty();
|
||||
if !is_empty {
|
||||
@@ -415,6 +420,20 @@ pub(crate) async fn home_page(db: &BlogDb) -> String {
|
||||
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>,
|
||||
@@ -428,14 +447,30 @@ where
|
||||
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:url" => {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
&["http://localhost", slug.as_ref()].concat()
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
&["https://", 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
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
&["http://localhost", image].concat()
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
&["https://", env!("DOMAIN"), image].concat()
|
||||
}
|
||||
} else {
|
||||
meta.remove();
|
||||
""
|
||||
@@ -463,7 +498,7 @@ where
|
||||
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 head_content = 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() {
|
||||
@@ -485,8 +520,17 @@ where
|
||||
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"),
|
||||
r#"<a class="animated-link" style="align-self: flex-start; margin-block-end: 0" href=""#,
|
||||
{
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
"http://localhost"
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
&["https://", env!("DOMAIN")].concat()
|
||||
}
|
||||
},
|
||||
"/blog/",
|
||||
&post.id,
|
||||
r#""><time>"#,
|
||||
@@ -497,8 +541,11 @@ where
|
||||
ContentType::Html,
|
||||
);
|
||||
Ok(())
|
||||
}), element!("head", |head| {
|
||||
head.replace(&head_content, ContentType::Html);
|
||||
head.append(&meta, ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
@@ -506,20 +553,30 @@ where
|
||||
html
|
||||
}
|
||||
|
||||
pub(crate) async fn blog_roll(db: &BlogDb) -> String {
|
||||
let mut posts = match db.get_posts().await {
|
||||
/// 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(),
|
||||
},
|
||||
};
|
||||
|
||||
posts.reverse();
|
||||
|
||||
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"),
|
||||
@@ -535,7 +592,7 @@ pub(crate) async fn blog_roll(db: &BlogDb) -> String {
|
||||
|
||||
make_page(
|
||||
blog_roll_html,
|
||||
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true),
|
||||
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true, true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -630,6 +687,7 @@ pub(crate) async fn search_page(
|
||||
["Results for “", &query, "”"].concat(),
|
||||
Some(vec!["/assets/css/blog.css"]),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -671,6 +729,7 @@ pub(crate) struct PageSettings {
|
||||
title: String,
|
||||
stylesheets: Option<Vec<String>>,
|
||||
site_header: bool,
|
||||
site_footer: bool,
|
||||
}
|
||||
|
||||
impl PageSettings {
|
||||
@@ -682,9 +741,15 @@ impl PageSettings {
|
||||
title: title.to_string(),
|
||||
stylesheets: None,
|
||||
site_header: true,
|
||||
site_footer: true,
|
||||
}
|
||||
}
|
||||
pub(crate) fn new<S, T>(title: S, stylesheets: Option<Vec<T>>, site_header: bool) -> Self
|
||||
pub(crate) fn new<S, T>(
|
||||
title: S,
|
||||
stylesheets: Option<Vec<T>>,
|
||||
site_header: bool,
|
||||
site_footer: bool,
|
||||
) -> Self
|
||||
where
|
||||
S: ToString,
|
||||
T: ToString,
|
||||
@@ -695,6 +760,7 @@ impl PageSettings {
|
||||
title: title.to_string(),
|
||||
stylesheets,
|
||||
site_header,
|
||||
site_footer,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +772,7 @@ where
|
||||
make_page(message, PageSettings::title("Not found"))
|
||||
}
|
||||
|
||||
pub(crate) fn animate_anchors<S>(input: S) -> String
|
||||
pub(crate) fn zhuzh_anchors<S>(input: S) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
@@ -714,6 +780,11 @@ where
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("a", move |t| {
|
||||
if let Some(href) = t.get_attribute("href") {
|
||||
if !href.starts_with("/") {
|
||||
t.set_attribute("target", "_blank").unwrap_or_default();
|
||||
}
|
||||
}
|
||||
match t.get_attribute("class") {
|
||||
Some(class) => t
|
||||
.set_attribute("class", &[&class, " animated-link-underline"].concat())
|
||||
@@ -744,3 +815,82 @@ pub(crate) fn sanitize<S: AsRef<str>>(input: S) -> String {
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn rss(db: &BlogDb) -> String {
|
||||
let posts = db.get_posts().await.unwrap_or_default();
|
||||
let mut items = String::new();
|
||||
for post in posts {
|
||||
let url = {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
["http://localhost/blog/", &post.id].concat()
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
["https://", env!("DOMAIN"), "/blog/", &post.id].concat()
|
||||
}
|
||||
};
|
||||
let item = rewrite_str(
|
||||
template!("item"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![
|
||||
element!("title", |title| {
|
||||
title.set_inner_content(&post.title, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("link", |link| {
|
||||
link.replace(&["<link>", &url, "</link>"].concat(), ContentType::Html); // slight hack to get around lol_html weirdness
|
||||
Ok(())
|
||||
}),
|
||||
element!("guid", |guid| {
|
||||
guid.set_inner_content(&url, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("pubDate", |pub_date| {
|
||||
let date = {
|
||||
let date: [u32; 3] = post
|
||||
.date
|
||||
.splitn(3, "-")
|
||||
.map(|d| d.parse::<u32>().unwrap_or_default())
|
||||
.collect::<Vec<u32>>()
|
||||
.try_into()
|
||||
.unwrap_or_default();
|
||||
let date_time: DateTime<FixedOffset> =
|
||||
FixedOffset::west_opt(5 * 3600) // New York City
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(date[0] as i32, date[1], date[2], 0, 0, 0)
|
||||
.unwrap();
|
||||
date_time.to_rfc2822()
|
||||
};
|
||||
pub_date.set_inner_content(&date, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("description", |description| {
|
||||
let post_content: Blocks =
|
||||
serde_json::from_str(&post.content).unwrap_or_default();
|
||||
let desc = ["<![CDATA[", &post_content.to_html(), "]]>"].concat();
|
||||
description.set_inner_content(&desc, ContentType::Html);
|
||||
Ok(())
|
||||
}),
|
||||
],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
items.push_str(&item);
|
||||
}
|
||||
|
||||
let feed = rewrite_str(
|
||||
template!("feed"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("channel", move |channel| {
|
||||
channel.append(&items, ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
feed
|
||||
}
|
||||
@@ -114,7 +114,7 @@ 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 = env!("DOMAIN").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?;
|
||||
@@ -34,6 +34,7 @@ pub(crate) enum Block {
|
||||
},
|
||||
quote {
|
||||
text: String,
|
||||
caption: String,
|
||||
},
|
||||
embed {
|
||||
service: String,
|
||||
@@ -78,7 +79,13 @@ impl Block {
|
||||
}
|
||||
},
|
||||
Block::warning { title } => ["<aside>", title, "</aside>"].concat(),
|
||||
Block::quote { text } => ["<blockquote>", text, "</blockquote>"].concat(),
|
||||
Block::quote { text, caption } => {
|
||||
if caption.is_empty() {
|
||||
["<figure><blockquote>“", text, "”</blockquote></figure>"].concat()
|
||||
} else {
|
||||
["<figure><blockquote>“", text, "”</blockquote><figcaption>— ", caption,"</figcaption></figure>"].concat()
|
||||
}
|
||||
},
|
||||
Block::embed {
|
||||
embed,
|
||||
width,
|
||||
@@ -91,16 +98,27 @@ impl Block {
|
||||
&height.to_string(),
|
||||
"\" src=\"",
|
||||
embed,
|
||||
"\"></iframe>",
|
||||
"\" allowfullscreen></iframe>",
|
||||
]
|
||||
.concat(),
|
||||
Block::image { file, caption } => {
|
||||
["<img src=\"", &file.url, "\" alt=\"", caption, "\"/>"].concat()
|
||||
let src = {
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
&file.url
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
&["https://", env!("DOMAIN"), &file.url].concat()
|
||||
}
|
||||
};
|
||||
["<img src=\"", src, "\" alt=\"", &caption.replace(r#"""#, """).replace("<br>", ""), "\"/>"].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"))
|
||||
html::zhuzh_anchors(html::remove_el(&text, "br"))
|
||||
}
|
||||
|
||||
fn to_plaintext(&self) -> String {
|
||||
@@ -109,7 +127,7 @@ impl Block {
|
||||
Block::header { text, level: _ } => text,
|
||||
Block::list { style: _, items } => &items.join("\n"),
|
||||
Block::warning { title } => title,
|
||||
Block::quote { text } => text,
|
||||
Block::quote { text, caption } => &[text, caption.as_str()].concat(),
|
||||
_ => &"".to_string(),
|
||||
};
|
||||
let text = [text, "\n"].concat();
|
||||
@@ -241,14 +259,17 @@ impl Blocks {
|
||||
Block::paragraph { text: _ } => true,
|
||||
Block::header { text: _, level } => *level > 1,
|
||||
Block::warning { title: _ } => true,
|
||||
Block::quote { text: _ } => true,
|
||||
Block::quote {
|
||||
text: _,
|
||||
caption: _,
|
||||
} => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|block| match block {
|
||||
Block::paragraph { text } => text,
|
||||
Block::header { text, level: _ } => text,
|
||||
Block::warning { title } => title,
|
||||
Block::quote { text } => text,
|
||||
Block::quote { text, caption: _ } => text,
|
||||
_ => "...",
|
||||
})
|
||||
.unwrap_or("No description"),
|
||||
@@ -38,6 +38,7 @@ pub(super) fn api(state: BlogState) -> Router {
|
||||
struct Entries {
|
||||
#[serde(default)]
|
||||
item: Vec<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -45,6 +46,7 @@ struct Entries {
|
||||
enum PostsEndpoints {
|
||||
delete,
|
||||
unpublish,
|
||||
date,
|
||||
}
|
||||
|
||||
async fn posts(
|
||||
@@ -71,6 +73,12 @@ async fn posts(
|
||||
state.index_delete(term);
|
||||
}
|
||||
}
|
||||
PostsEndpoints::date => {
|
||||
let date = &data.date.clone().unwrap_or("1970-01-01".to_string());
|
||||
for post_id in data.item.iter() {
|
||||
let _ = db.set_post_date(post_id, &date).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.index_commit();
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -62,6 +62,7 @@ pub(super) async fn make_router(state: BlogState) -> Router {
|
||||
.nest("/api", api(state.clone()))
|
||||
.nest("/login", login(State(state.clone())))
|
||||
.route("/", get(pages).with_state(state.clone()))
|
||||
.route("/feed.xml", get(rss).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()))
|
||||
@@ -308,6 +309,15 @@ async fn admin(jar: PrivateCookieJar, State(state): State<BlogState>) -> Respons
|
||||
(headers, Html(page)).into_response()
|
||||
}
|
||||
|
||||
async fn rss(State(state): State<BlogState>) -> Response {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str("application/xml").unwrap(),
|
||||
);
|
||||
(headers, html::rss(state.db()).await).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||
pub(crate) struct SearchResponse(Vec<SearchResponseEntry>);
|
||||
impl SearchResponse {
|
||||
+4
-1
@@ -3,11 +3,14 @@
|
||||
<h1>Published Posts</h1>
|
||||
</div>
|
||||
<form method="post">
|
||||
<div class="form-actions">
|
||||
<div class="form-actions" id="post-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>
|
||||
<button id="date-update" class="form-action" type="submit" formaction="#update-date" formmethod="get">Edit
|
||||
Date
|
||||
</button>
|
||||
</div>
|
||||
<ul tabindex="-1">
|
||||
</ul>
|
||||
@@ -3,3 +3,4 @@
|
||||
<admin-widget type="drafts"></admin-widget>
|
||||
<admin-widget type="posts"></admin-widget>
|
||||
</div>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
@@ -2,8 +2,8 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" />
|
||||
</head>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<site-header></site-header>
|
||||
<main>
|
||||
</main>
|
||||
<site-footer></site-footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Evie Ippolito's Blog</title>
|
||||
<link>https://evieippolito.com/blog</link>
|
||||
<atom:link href="https://evieippolito.com/feed.xml" rel="self" type="application/rss+xml" />
|
||||
<description>Evie's blog!</description>
|
||||
<language>en-us</language>
|
||||
<ttl>720</ttl>
|
||||
<managingEditor>evieippolito@duck.com (Evie Ippolito)</managingEditor>
|
||||
<webMaster>me@augustkline.com (august kline)</webMaster>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,7 @@
|
||||
<item>
|
||||
<title></title>
|
||||
<link>
|
||||
<guid isPermaLink="true"></guid>
|
||||
<pubDate></pubDate>
|
||||
<description></description>
|
||||
</item>
|
||||
@@ -0,0 +1,56 @@
|
||||
<footer>
|
||||
<div>
|
||||
<h1>keep in touch</h1>
|
||||
<div>
|
||||
<form action="https://buttondown.com/api/emails/embed-subscribe/eviewrites" method="post"
|
||||
target="popupwindow" onsubmit="window.open('https://buttondown.com/eviewrites', 'popupwindow')"
|
||||
class="embeddable-buttondown-form">
|
||||
<label for="bd-email">
|
||||
<p>Subscribe to my newsletter for updates <3</p>
|
||||
</label>
|
||||
<div>
|
||||
<input type="email" name="email" id="bd-email" required />
|
||||
<input type="submit" value="Subscribe" style="border-inline-start: none;" />
|
||||
</div>
|
||||
</form>
|
||||
<p>Or subscribe to my <a class="animated-link-underline" href="/feed.xml">RSS feed</a>! RSS rules, you can
|
||||
learn more
|
||||
about
|
||||
it <a href="https://guides.library.yale.edu/keepingup/basics" class="animated-link-underline"
|
||||
target="_blank" rel="noreferrer">here</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1>credits</h1>
|
||||
<div>
|
||||
<p><a href="https://augustkline.com" class="animated-link-underline" target="_blank" rel="noreferrer">august
|
||||
kline</a>
|
||||
made this website. <a href="mailto:inquiries@augustkline.com" class="animated-link-underline">give her
|
||||
money</a> and she will make you
|
||||
one too.</p>
|
||||
<p><a href="https://www.redaction.us/" class="animated-link-underline" target="_blank"
|
||||
rel="noreferrer">Redaction</a> and <a href="https://www.brailleinstitute.org/freefont/"
|
||||
class="animated-link-underline" target="_blank" rel="noreferrer">Atkinson Hyperlegible</a> are used
|
||||
as display and text typefaces,
|
||||
respectively.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1>elsewhere</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.instagram.com/everzines/" class="animated-link" target="_blank"
|
||||
rel="noreferrer">insta</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="mailto:evieippolito@duck.com" class="animated-link">email</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
# this only works on macos for now, cross compilation toolchain from https://github.com/messense/homebrew-macos-cross-toolchains
|
||||
|
||||
set -e
|
||||
|
||||
cargo update;
|
||||
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc cargo build --release --target x86_64-unknown-linux-gnu;
|
||||
DOMAIN=staging.evieippolito.com CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc cargo build --release --target x86_64-unknown-linux-gnu;
|
||||
|
||||
scp target/x86_64-unknown-linux-gnu/release/evie kline@augustkline.com:/home/kline/;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user