This commit is contained in:
Dominic Grimm 2023-02-11 12:48:39 +01:00
parent 584c07ff23
commit 53e144d9a7
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
24 changed files with 1236 additions and 254 deletions

View file

@ -1,4 +1,3 @@
use diesel::QueryDsl;
#[cfg(not(target_env = "msvc"))]
use tikv_jemallocator::Jemalloc;
@ -6,104 +5,23 @@ use tikv_jemallocator::Jemalloc;
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
use actix_web::{get, http::StatusCode, middleware, web, App, HttpResponse, HttpServer};
use actix_web_static_files::ResourceFiles;
use askama_actix::{Template, TemplateToResponse};
use diesel::prelude::*;
use backend::*;
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
mod filters {
pub fn cmark<T: std::fmt::Display>(s: T) -> ::askama::Result<String> {
let s = s.to_string();
let options = pulldown_cmark::Options::empty();
let parser = pulldown_cmark::Parser::new_ext(&s, options);
let mut html_output = String::with_capacity(s.len() * 3 / 2);
pulldown_cmark::html::push_html(&mut html_output, parser);
Ok(html_output)
}
}
#[derive(Template)]
#[template(path = "status_code.html")]
struct StatusCodeTemplate {
status_code: StatusCode,
message: Option<String>,
}
#[derive(Template)]
#[template(path = "posts/{slug}.html")]
struct PostBySlugTemplate {
post: db::models::Post,
}
async fn not_found() -> HttpResponse {
StatusCodeTemplate {
status_code: StatusCode::NOT_FOUND,
message: None,
}
.to_response()
}
#[get("/posts/{slug}")]
async fn post_by_slug(db_pool: web::Data<db::DbPool>, path: web::Path<String>) -> HttpResponse {
let slug = path.into_inner();
let conn = &mut match db_pool.get() {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let post = match db::schema::posts::table
.filter(db::schema::posts::slug.eq(&slug))
.filter(db::schema::posts::active)
.first::<db::models::Post>(conn)
.optional()
{
Ok(x) => x,
Err(e) => {
dbg!(&e);
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
};
match post {
Some(x) => PostBySlugTemplate { post: x }.to_response(),
None => {
let mut resp = StatusCodeTemplate {
status_code: StatusCode::NOT_FOUND,
message: None,
}
.to_response();
*resp.status_mut() = StatusCode::NOT_FOUND;
resp
}
}
}
use actix_web::{middleware, App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
log::info!("Listening to requests at http://0.0.0.0:80");
log::info!(
"Listening to requests at {}",
backend::config::CONFIG.bind_url
);
HttpServer::new(move || {
let generated = generate();
App::new()
.wrap(middleware::Compress::default())
.wrap(middleware::Logger::default())
.app_data(web::Data::new(db::pool().unwrap()))
.service(post_by_slug)
.service(ResourceFiles::new("/static", generated))
.default_service(web::route().to(not_found))
.configure(backend::web::init)
})
.bind("0.0.0.0:80")
.bind(&backend::config::CONFIG.bind_url)
.unwrap()
.run()
.await

View file

@ -9,6 +9,7 @@ use anyhow::{bail, Result};
use chrono::prelude::*;
use clap::{Parser, Subcommand};
use diesel::prelude::*;
use r2d2_redis::redis;
use scan_dir::ScanDir;
use serde::{Deserialize, Serialize};
use std::fs;
@ -27,6 +28,9 @@ struct Cli {
enum Commands {
#[clap(about = "Imports new posts")]
Import,
#[clap(about = "Clears redis cache")]
Clear,
}
#[derive(Deserialize, Serialize, Debug)]
@ -50,88 +54,137 @@ struct Post {
fn main() -> Result<()> {
match Cli::parse().commands {
Commands::Import => {
let conn = &mut db::establish_connection()?;
let db_conn = &mut db::establish_connection()?;
let redis_conn = &mut cache::establish_connection()?;
for post in ScanDir::dirs().read("/blog", |iter| {
iter.map(|(entry, _)| {
let path = entry.path().join("post.md");
let src = fs::read_to_string(&path)?;
let frontmatter = match fronma::parser::parse::<PostFrontmatter>(&src) {
Ok(x) => x,
Err(x) => bail!("Error parsing frontmatter: {:?}", x),
};
let posts = ScanDir::dirs()
.read("/blog", |iter| {
iter.map(|(entry, _)| {
let path = entry.path().join("post.md");
let src = fs::read_to_string(&path)?;
let frontmatter = match fronma::parser::parse::<PostFrontmatter>(&src) {
Ok(x) => x,
Err(x) => bail!("Error parsing frontmatter: {:?}", x),
};
Ok(Post {
path,
frontmatter: frontmatter.headers,
content: frontmatter.body.to_string(),
})
})
.collect::<Result<Vec<_>>>()
})?? {
let content = post.content.trim();
if let Some(id) = post.frontmatter.id {
diesel::update(db::schema::posts::table)
.filter(db::schema::posts::id.eq(id))
.set(db::models::UpdatePost {
name: Some(&post.frontmatter.name),
slug: Some(&post.frontmatter.slug),
description: Some(&post.frontmatter.description),
content: Some(content),
published_at: Some(post.frontmatter.published_at),
edited_at: Some(post.frontmatter.edited_at),
active: Some(post.frontmatter.active),
Ok(Post {
path,
frontmatter: frontmatter.headers,
content: frontmatter.body.to_string(),
})
.execute(conn)?;
} else {
let id = if let Some(id) = db::schema::posts::table
.select(db::schema::posts::id)
.filter(db::schema::posts::slug.eq(&post.frontmatter.slug))
.first::<i32>(conn)
.optional()?
{
})
.collect::<Result<Vec<_>>>()
})??
.into_iter()
.map(|post| -> Result<_> {
let trimmed = PostFrontmatter {
id: post.frontmatter.id,
name: post.frontmatter.name.trim().to_string(),
slug: post.frontmatter.slug.trim().to_string(),
description: post.frontmatter.description.trim().to_string(),
published_at: post.frontmatter.published_at,
edited_at: post.frontmatter.edited_at,
active: post.frontmatter.active,
};
let content = post.content.trim();
if let Some(id) = trimmed.id {
diesel::update(db::schema::posts::table)
.filter(db::schema::posts::id.eq(id))
.set(db::models::UpdatePost {
name: Some(&post.frontmatter.name),
slug: None,
description: Some(&post.frontmatter.description),
name: Some(&trimmed.name),
slug: Some(&trimmed.slug),
description: Some(&trimmed.description),
content: Some(content),
published_at: Some(post.frontmatter.published_at),
edited_at: Some(post.frontmatter.edited_at),
active: Some(post.frontmatter.active),
published_at: Some(trimmed.published_at),
edited_at: Some(trimmed.edited_at),
active: Some(trimmed.active),
})
.execute(conn)?;
.execute(db_conn)?;
id
Ok(id)
} else {
diesel::insert_into(db::schema::posts::table)
.values(db::models::NewPost {
name: &post.frontmatter.name,
slug: &post.frontmatter.slug,
description: &post.frontmatter.description,
content: content,
published_at: post.frontmatter.published_at,
edited_at: post.frontmatter.edited_at,
active: post.frontmatter.active,
})
.returning(db::schema::posts::id)
.get_result::<i32>(conn)?
};
let id = if let Some(id) = db::schema::posts::table
.select(db::schema::posts::id)
.filter(db::schema::posts::slug.eq(&trimmed.slug))
.first::<i32>(db_conn)
.optional()?
{
diesel::update(db::schema::posts::table)
.filter(db::schema::posts::id.eq(id))
.set(db::models::UpdatePost {
name: Some(&trimmed.name),
slug: None,
description: Some(&trimmed.description),
content: Some(content),
published_at: Some(trimmed.published_at),
edited_at: Some(trimmed.edited_at),
active: Some(trimmed.active),
})
.execute(db_conn)?;
fs::write(
post.path,
format!(
"---\n{}---\n{}",
serde_yaml::to_string(&PostFrontmatter {
id: Some(id),
..post.frontmatter
})?,
post.content
),
)?;
}
id
} else {
diesel::insert_into(db::schema::posts::table)
.values(db::models::NewPost {
name: &trimmed.name,
slug: &trimmed.slug,
description: &trimmed.description,
content: content,
published_at: trimmed.published_at,
edited_at: trimmed.edited_at,
active: trimmed.active,
})
.returning(db::schema::posts::id)
.get_result::<i32>(db_conn)?
};
fs::write(
post.path,
format!(
"---\n{}---\n\n{}\n",
serde_yaml::to_string(&PostFrontmatter {
id: Some(id),
..trimmed
})?,
content
),
)?;
Ok(id)
}
})
.collect::<Result<Vec<_>>>()?;
let ids = db::schema::posts::table
.select(db::schema::posts::id)
.load::<i32>(db_conn)?;
diesel::delete(
db::schema::posts::table
.filter(diesel::dsl::not(db::schema::posts::id.eq_any(posts))),
)
.execute(db_conn)?;
for id in ids {
redis::cmd("DEL")
.arg(cache::keys::post_content(id))
.query::<()>(redis_conn)?;
}
Ok(())
}
Commands::Clear => {
let db_conn = &mut db::establish_connection()?;
let redis_conn = &mut cache::establish_connection()?;
for id in db::schema::posts::table
.select(db::schema::posts::id)
.load::<i32>(db_conn)?
{
redis::cmd("DEL")
.arg(cache::keys::post_content(id))
.query::<()>(redis_conn)?;
}
Ok(())

28
backend/src/cache.rs Normal file
View file

@ -0,0 +1,28 @@
use anyhow::Result;
use lazy_static::lazy_static;
use r2d2_redis::{r2d2, redis, RedisConnectionManager};
use crate::config;
pub type RedisPool = r2d2::Pool<RedisConnectionManager>;
pub type ConnectionPool = r2d2::PooledConnection<RedisConnectionManager>;
pub fn establish_connection() -> Result<redis::Connection> {
Ok(redis::Client::open(config::CONFIG.redis_url.as_str())?.get_connection()?)
}
pub fn pool() -> Result<RedisPool> {
Ok(r2d2::Pool::builder().build(RedisConnectionManager::new(
config::CONFIG.redis_url.as_str(),
)?)?)
}
lazy_static! {
pub static ref POOL: RedisPool = pool().unwrap();
}
pub mod keys {
pub fn post_content(id: i32) -> String {
format!("post_content:{}", id)
}
}

View file

@ -1,11 +1,19 @@
use envconfig::Envconfig;
use lazy_static::lazy_static;
#[derive(Envconfig, Debug)]
pub struct Config {
#[envconfig(from = "BACKEND_BIND_URL")]
pub bind_url: String,
#[envconfig(from = "BACKEND_DB_URL")]
pub db_url: String,
#[envconfig(from = "BACKEND_REDIS_URL")]
pub redis_url: String,
#[envconfig(from = "BACKEND_CACHE_POST_CONTENT_TTL")]
pub cache_post_content_ttl: usize,
}
lazy_static! {

View file

@ -1,2 +1,5 @@
pub mod cache;
pub mod config;
pub mod db;
pub mod markdown;
pub mod web;

28
backend/src/markdown.rs Normal file
View file

@ -0,0 +1,28 @@
pub fn to_html(src: &str) -> String {
let adapter = comrak::plugins::syntect::SyntectAdapter::new("InspiredGitHub");
let options = comrak::ComrakOptions {
extension: comrak::ComrakExtensionOptions {
strikethrough: true,
tagfilter: true,
table: true,
autolink: true,
tasklist: true,
superscript: true,
header_ids: Some("__blog-content_".to_string()),
footnotes: true,
description_lists: true,
front_matter_delimiter: None,
shortcodes: true,
},
parse: comrak::ComrakParseOptions {
smart: true,
..comrak::ComrakParseOptions::default()
},
..comrak::ComrakOptions::default()
};
let mut plugins = comrak::ComrakPlugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter);
comrak::markdown_to_html_with_plugins(src, &options, &plugins)
}

176
backend/src/web/mod.rs Normal file
View file

@ -0,0 +1,176 @@
use actix_web::{get, http, web, HttpResponse};
use actix_web_static_files::ResourceFiles;
use askama_actix::TemplateToResponse;
use diesel::prelude::*;
use r2d2_redis::redis;
use std::ops::DerefMut;
use crate::{cache, config, db, markdown};
pub mod templates;
pub mod static_dir {
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
}
async fn not_found() -> HttpResponse {
let mut resp = templates::StatusCode {
status_code: http::StatusCode::NOT_FOUND,
message: Some("maybe try a correct url?".to_string()),
}
.to_response();
*resp.status_mut() = http::StatusCode::NOT_FOUND;
resp
}
#[get("/posts")]
async fn posts(db_pool: web::Data<db::DbPool>) -> HttpResponse {
let db_conn = &mut match db_pool.get() {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let posts = match db::schema::posts::table
.filter(db::schema::posts::active)
.order(db::schema::posts::published_at.desc())
.load::<db::models::Post>(db_conn)
{
Ok(x) => x,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
};
templates::Posts { posts }.to_response()
}
#[get("/")]
async fn index() -> HttpResponse {
templates::Index.to_response()
}
#[get("/about")]
async fn about() -> HttpResponse {
templates::About.to_response()
}
#[get("/posts/{slug}")]
async fn post_by_slug(
db_pool: web::Data<db::DbPool>,
redis_pool: web::Data<cache::RedisPool>,
path: web::Path<String>,
) -> HttpResponse {
let slug = path.into_inner();
let db_conn = &mut match db_pool.get() {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let redis_conn = &mut match redis_pool.get() {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let post_stripped: Option<(i32, String)> = match db::schema::posts::table
.select((db::schema::posts::id, db::schema::posts::name))
.filter(db::schema::posts::slug.eq(&slug))
.filter(db::schema::posts::active)
.get_result::<(i32, String)>(db_conn)
.optional()
{
Ok(x) => x,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
};
match post_stripped {
Some(stripped) => {
let (stripped_id, stripped_name) = stripped;
let key = cache::keys::post_content(stripped_id);
match match redis::cmd("GET")
.arg(&key)
.query::<Option<String>>(redis_conn.deref_mut())
{
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
} {
Some(s) => templates::PostBySlug {
name: stripped_name,
slug,
content: s,
}
.to_response(),
None => {
let post = match db::schema::posts::table
.filter(db::schema::posts::id.eq(stripped_id))
.first::<db::models::Post>(db_conn)
{
Ok(x) => x,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
};
let html = markdown::to_html(&post.content);
match redis::cmd("SET")
.arg(&key)
.arg(&html)
.query::<Option<String>>(redis_conn.deref_mut())
{
Ok(x) => x,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e))
}
};
if let Err(e) = redis::cmd("EXPIRE")
.arg(key)
.arg(config::CONFIG.cache_post_content_ttl)
.query::<()>(redis_conn.deref_mut())
{
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
templates::PostBySlug {
name: post.name,
slug: post.slug,
content: html,
}
.to_response()
}
}
}
None => {
let mut resp = templates::StatusCode {
status_code: http::StatusCode::NOT_FOUND,
message: Some("this post does not exists... yet".to_string()),
}
.to_response();
*resp.status_mut() = http::StatusCode::NOT_FOUND;
resp
}
}
}
fn setup_routes(cfg: &mut web::ServiceConfig) {
let generated = static_dir::generate();
cfg.service(index)
.service(about)
.service(posts)
.service(ResourceFiles::new("/static", generated))
.service(post_by_slug)
.default_service(web::route().to(not_found));
}
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.app_data(web::Data::new(db::pool().unwrap()))
.app_data(web::Data::new(cache::pool().unwrap()));
setup_routes(cfg);
}

View file

@ -0,0 +1,33 @@
use actix_web::http;
use askama_actix::Template;
use crate::db;
#[derive(Template)]
#[template(path = "status_code.html")]
pub struct StatusCode {
pub status_code: http::StatusCode,
pub message: Option<String>,
}
#[derive(Template)]
#[template(path = "web/index.html")]
pub struct Index;
#[derive(Template)]
#[template(path = "web/about.html")]
pub struct About;
#[derive(Template)]
#[template(path = "web/posts/index.html")]
pub struct Posts {
pub posts: Vec<db::models::Post>,
}
#[derive(Template)]
#[template(path = "web/posts/{slug}.html")]
pub struct PostBySlug {
pub name: String,
pub slug: String,
pub content: String,
}