This commit is contained in:
Dominic Grimm 2023-02-12 09:18:56 +01:00
parent 501b9d3093
commit 964534d0d9
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
21 changed files with 762 additions and 207 deletions

View file

@ -39,3 +39,11 @@ insert_final_newline = true
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.md]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

View file

@ -1,6 +1,9 @@
.PHONY: all build .PHONY: all build ci
all: build all: build
build: build:
docker compose build
ci:
BUILDKIT_PROGRESS=plain docker compose build BUILDKIT_PROGRESS=plain docker compose build

21
backend/Cargo.lock generated
View file

@ -355,11 +355,13 @@ dependencies = [
"env_logger", "env_logger",
"envconfig", "envconfig",
"fronma", "fronma",
"itertools",
"lazy_static", "lazy_static",
"log", "log",
"r2d2_redis", "r2d2_redis",
"scan_dir", "scan_dir",
"serde", "serde",
"serde_json",
"serde_yaml 0.9.17", "serde_yaml 0.9.17",
"static-files", "static-files",
"tikv-jemallocator", "tikv-jemallocator",
@ -777,6 +779,12 @@ version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0"
[[package]]
name = "either"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]] [[package]]
name = "emojis" name = "emojis"
version = "0.5.2" version = "0.5.2"
@ -1111,6 +1119,15 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.8" version = "0.4.8"
@ -1784,9 +1801,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.92" version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a" checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76"
dependencies = [ dependencies = [
"itoa 1.0.5", "itoa 1.0.5",
"ryu", "ryu",

View file

@ -33,11 +33,13 @@ diesel = { version = "2.0.2", features = ["i-implement-a-third-party-backend-and
env_logger = "0.10.0" env_logger = "0.10.0"
envconfig = "0.10.0" envconfig = "0.10.0"
fronma = "0.1.1" fronma = "0.1.1"
itertools = "0.10.5"
lazy_static = "1.4.0" lazy_static = "1.4.0"
log = "0.4.17" log = "0.4.17"
r2d2_redis = "0.14.0" r2d2_redis = "0.14.0"
scan_dir = "0.3.3" scan_dir = "0.3.3"
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
serde_yaml = "0.9.17" serde_yaml = "0.9.17"
static-files = "0.2.3" static-files = "0.2.3"

View file

@ -29,13 +29,13 @@ RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder FROM chef as builder
WORKDIR /usr/src/backend WORKDIR /usr/src/backend
COPY --from=planner /usr/src/backend/recipe.json . COPY --from=planner /usr/src/backend/recipe.json .
RUN cargo chef cook --recipe-path recipe.json RUN cargo chef cook --release --recipe-path recipe.json
RUN rm -rf ./src RUN rm -rf ./src
COPY ./build.rs . COPY ./build.rs .
COPY --from=static /usr/src/static ./static COPY --from=static /usr/src/static ./static
COPY --from=templates /usr/src/templates ./templates COPY --from=templates /usr/src/templates ./templates
COPY ./src ./src COPY ./src ./src
RUN cargo build RUN cargo build --release
FROM docker.io/debian:bullseye-slim as runner FROM docker.io/debian:bullseye-slim as runner
LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \ LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \
@ -45,7 +45,6 @@ LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \
org.opencontainers.image.url="https://git.dergrimm.net/dergrimm/blog" org.opencontainers.image.url="https://git.dergrimm.net/dergrimm/blog"
RUN apt update RUN apt update
RUN apt install -y libpq5 RUN apt install -y libpq5
RUN apt install -y ca-certificates
RUN apt-get clean RUN apt-get clean
RUN apt-get autoremove -y RUN apt-get autoremove -y
RUN rm -rf /var/lib/{apt,dpkg,cache,log}/ RUN rm -rf /var/lib/{apt,dpkg,cache,log}/
@ -55,5 +54,5 @@ WORKDIR /usr/src/backend
COPY ./run.sh . COPY ./run.sh .
RUN chmod +x ./run.sh RUN chmod +x ./run.sh
COPY ./migrations ./migrations COPY ./migrations ./migrations
COPY --from=builder /usr/src/backend/target/debug/backend /usr/src/backend/target/debug/blogctl ./bin/ COPY --from=builder /usr/src/backend/target/release/backend /usr/src/backend/target/release/blogctl ./bin/
ENTRYPOINT [ "./run.sh" ] ENTRYPOINT [ "./run.sh" ]

View file

@ -1,3 +1,5 @@
DROP TABLE post_tags;
DROP TABLE posts; DROP TABLE posts;
DROP TABLE tags; DROP TABLE tags;

View file

@ -13,3 +13,9 @@ CREATE TABLE posts(
edited_at DATE, edited_at DATE,
active BOOLEAN NOT NULL active BOOLEAN NOT NULL
); );
CREATE TABLE post_tags(
id SERIAL PRIMARY KEY,
post_id INTEGER NOT NULL REFERENCES posts(id),
tag_id INTEGER NOT NULL REFERENCES tags(id)
);

View file

@ -45,7 +45,7 @@ hr {
ul.dashed { ul.dashed {
list-style-type: none; list-style-type: none;
li:before { & > li:before {
content: "-"; content: "-";
position: absolute; position: absolute;
// margin-left: -20px; // margin-left: -20px;
@ -107,7 +107,7 @@ table.border-rows {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; // overflow: hidden;
float: right; float: right;
li { li {
@ -138,9 +138,37 @@ table.border-rows {
font-size: small; font-size: small;
} }
#post-index { ul.post-index > li:not(:last-child) {
li:not(:last-child) { margin-bottom: 1rem;
margin-bottom: 1rem; }
ul.tag-list {
list-style-type: none;
margin: 0;
padding: 0;
& > li {
float: left;
&:not(:first-of-type) {
padding-left: 0.5rem;
}
&:not(:last-of-type)::after {
content: ",";
}
}
}
#blog-meta {
th,
td {
text-align: left;
vertical-align: top;
}
th {
padding-right: 1em;
} }
} }
@ -204,6 +232,11 @@ table.border-rows {
border: thin solid black; border: thin solid black;
overflow-x: scroll; overflow-x: scroll;
} }
blockquote {
padding: 0 1rem;
border-left: medium solid black;
}
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {

View file

@ -9,7 +9,7 @@ use anyhow::{bail, Result};
use chrono::prelude::*; use chrono::prelude::*;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use diesel::prelude::*; use diesel::prelude::*;
use r2d2_redis::redis; use itertools::Itertools;
use scan_dir::ScanDir; use scan_dir::ScanDir;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@ -33,12 +33,13 @@ enum Commands {
Clear, Clear,
} }
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
struct PostFrontmatter { struct PostFrontmatter {
id: Option<i32>, id: Option<i32>,
name: String, name: String,
slug: String, slug: String,
description: String, description: String,
tags: Vec<String>,
published_at: NaiveDate, published_at: NaiveDate,
edited_at: Option<NaiveDate>, edited_at: Option<NaiveDate>,
active: bool, active: bool,
@ -57,9 +58,9 @@ fn main() -> Result<()> {
let db_conn = &mut db::establish_connection()?; let db_conn = &mut db::establish_connection()?;
let redis_conn = &mut cache::establish_connection()?; let redis_conn = &mut cache::establish_connection()?;
let posts = ScanDir::dirs() let (tags_raw, posts_raw) = ScanDir::dirs().read("/blog", |iter| -> Result<_> {
.read("/blog", |iter| { let x = iter
iter.map(|(entry, _)| { .map(|(entry, _)| {
let path = entry.path().join("post.md"); let path = entry.path().join("post.md");
let src = fs::read_to_string(&path)?; let src = fs::read_to_string(&path)?;
let frontmatter = match fronma::parser::parse::<PostFrontmatter>(&src) { let frontmatter = match fronma::parser::parse::<PostFrontmatter>(&src) {
@ -67,54 +68,74 @@ fn main() -> Result<()> {
Err(x) => bail!("Error parsing frontmatter: {:?}", x), Err(x) => bail!("Error parsing frontmatter: {:?}", x),
}; };
Ok(Post { Ok((
path, frontmatter.headers.tags.to_owned(),
frontmatter: frontmatter.headers, Post {
content: frontmatter.body.to_string(), path,
}) frontmatter: frontmatter.headers,
content: frontmatter.body.to_string(),
},
))
}) })
.collect::<Result<Vec<_>>>() .collect::<Result<Vec<(Vec<String>, Post)>>>()?;
})??
let tags: Vec<String> = x.iter().flat_map(|y| y.0.to_owned()).unique().collect();
let posts: Vec<Post> = x.into_iter().map(|y| y.1).collect();
Ok((tags, posts))
})??;
let tags = tags_raw
.into_iter() .into_iter()
.map(|post| -> Result<_> { .map(|tag| {
Ok(
match db::schema::tags::table
.select(db::schema::tags::id)
.filter(db::schema::tags::name.eq(&tag))
.first::<i32>(db_conn)
.optional()?
{
Some(x) => x,
None => diesel::insert_into(db::schema::tags::table)
.values(db::models::NewTag { name: &tag })
.returning(db::schema::tags::id)
.get_result::<i32>(db_conn)?,
},
)
})
.collect::<Result<Vec<_>>>()?;
diesel::delete(
db::schema::tags::table.filter(diesel::dsl::not(db::schema::tags::id.eq_any(tags))),
)
.execute(db_conn)?;
let posts = posts_raw
.into_iter()
.map(|post| {
let trimmed = PostFrontmatter { let trimmed = PostFrontmatter {
id: post.frontmatter.id, id: post.frontmatter.id,
name: post.frontmatter.name.trim().to_string(), name: post.frontmatter.name.trim().to_string(),
slug: post.frontmatter.slug.trim().to_string(), slug: post.frontmatter.slug.trim().to_string(),
description: post.frontmatter.description.trim().to_string(), description: post.frontmatter.description.trim().to_string(),
tags: post
.frontmatter
.tags
.iter()
.map(|x| x.trim().to_string())
.collect(),
published_at: post.frontmatter.published_at, published_at: post.frontmatter.published_at,
edited_at: post.frontmatter.edited_at, edited_at: post.frontmatter.edited_at,
active: post.frontmatter.active, active: post.frontmatter.active,
}; };
let content = post.content.trim(); let content = post.content.trim();
if let Some(id) = trimmed.id { let id = {
diesel::update(db::schema::posts::table) let res: Result<i32, anyhow::Error> = if let Some(id) = trimmed.id {
.filter(db::schema::posts::id.eq(id))
.set(db::models::UpdatePost {
name: Some(&trimmed.name),
slug: Some(&trimmed.slug),
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)?;
Ok(id)
} else {
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) diesel::update(db::schema::posts::table)
.filter(db::schema::posts::id.eq(id)) .filter(db::schema::posts::id.eq(id))
.set(db::models::UpdatePost { .set(db::models::UpdatePost {
name: Some(&trimmed.name), name: Some(&trimmed.name),
slug: None, slug: Some(&trimmed.slug),
description: Some(&trimmed.description), description: Some(&trimmed.description),
content: Some(content), content: Some(content),
published_at: Some(trimmed.published_at), published_at: Some(trimmed.published_at),
@ -123,69 +144,111 @@ fn main() -> Result<()> {
}) })
.execute(db_conn)?; .execute(db_conn)?;
id Ok(id)
} else { } else {
diesel::insert_into(db::schema::posts::table) let id = if let Some(id) = db::schema::posts::table
.values(db::models::NewPost { .select(db::schema::posts::id)
name: &trimmed.name, .filter(db::schema::posts::slug.eq(&trimmed.slug))
slug: &trimmed.slug, .first::<i32>(db_conn)
description: &trimmed.description, .optional()?
content: content, {
published_at: trimmed.published_at, diesel::update(db::schema::posts::table)
edited_at: trimmed.edited_at, .filter(db::schema::posts::id.eq(id))
active: trimmed.active, .set(db::models::UpdatePost {
}) name: Some(&trimmed.name),
.returning(db::schema::posts::id) slug: None,
.get_result::<i32>(db_conn)? 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)?;
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.to_owned()
})?,
content
),
)?;
Ok(id)
}; };
fs::write( res
post.path, }?;
format!(
"---\n{}---\n\n{}\n",
serde_yaml::to_string(&PostFrontmatter {
id: Some(id),
..trimmed
})?,
content
),
)?;
Ok(id) let tag_ids = db::schema::tags::table
} .select(db::schema::tags::id)
.filter(db::schema::tags::name.eq_any(trimmed.tags))
.load::<i32>(db_conn)?;
let post_tags = db::schema::post_tags::table
.filter(db::schema::post_tags::post_id.eq(id))
.load::<db::models::PostTag>(db_conn)?;
diesel::delete(
db::schema::post_tags::table
.filter(db::schema::post_tags::post_id.eq(id))
.filter(diesel::dsl::not(
db::schema::post_tags::tag_id.eq_any(&tag_ids),
)),
)
.execute(db_conn)?;
let post_tag_tag_ids: Vec<_> = post_tags.iter().map(|x| x.tag_id).collect();
diesel::insert_into(db::schema::post_tags::table)
.values(
tag_ids
.into_iter()
.filter(|x| !post_tag_tag_ids.contains(x))
.map(|x| db::models::NewPostTag {
post_id: id,
tag_id: x,
})
.collect::<Vec<_>>(),
)
.execute(db_conn)?;
Ok(id)
}) })
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
let ids = db::schema::posts::table
.select(db::schema::posts::id)
.load::<i32>(db_conn)?;
diesel::delete( diesel::delete(
db::schema::posts::table db::schema::posts::table
.filter(diesel::dsl::not(db::schema::posts::id.eq_any(posts))), .filter(diesel::dsl::not(db::schema::posts::id.eq_any(posts))),
) )
.execute(db_conn)?; .execute(db_conn)?;
for id in ids { cache::clear(redis_conn)?;
redis::cmd("DEL")
.arg(cache::keys::post_content(id))
.query::<()>(redis_conn)?;
}
Ok(()) Ok(())
} }
Commands::Clear => { Commands::Clear => {
let db_conn = &mut db::establish_connection()?;
let redis_conn = &mut cache::establish_connection()?; let redis_conn = &mut cache::establish_connection()?;
for id in db::schema::posts::table cache::clear(redis_conn)?;
.select(db::schema::posts::id)
.load::<i32>(db_conn)?
{
redis::cmd("DEL")
.arg(cache::keys::post_content(id))
.query::<()>(redis_conn)?;
}
Ok(()) Ok(())
} }

View file

@ -1,11 +1,15 @@
use anyhow::Result; use anyhow::Result;
use chrono::prelude::*;
use diesel::prelude::*;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use r2d2_redis::{r2d2, redis, RedisConnectionManager}; use r2d2_redis::{r2d2, redis, RedisConnectionManager};
use serde::{Deserialize, Serialize};
use crate::config; use crate::{config, db, markdown, web};
pub type RedisPool = r2d2::Pool<RedisConnectionManager>; pub type RedisPool = r2d2::Pool<RedisConnectionManager>;
pub type ConnectionPool = r2d2::PooledConnection<RedisConnectionManager>; pub type ConnectionPool = r2d2::PooledConnection<RedisConnectionManager>;
pub type Connection = redis::Connection;
pub fn establish_connection() -> Result<redis::Connection> { pub fn establish_connection() -> Result<redis::Connection> {
Ok(redis::Client::open(config::CONFIG.redis_url.as_str())?.get_connection()?) Ok(redis::Client::open(config::CONFIG.redis_url.as_str())?.get_connection()?)
@ -22,7 +26,201 @@ lazy_static! {
} }
pub mod keys { pub mod keys {
pub fn post_content(id: i32) -> String { pub const POSTS: &str = "posts";
format!("post_content:{}", id)
pub const POST: &str = "post:";
pub fn post(id: i32) -> String {
format!("{}{}", POST, id)
}
pub const TAG_POSTS: &str = "tag_posts:";
pub fn tag_posts(id: i32) -> String {
format!("{}{}", TAG_POSTS, id)
}
}
pub fn clear(redis_conn: &mut Connection) -> Result<()> {
redis::cmd("UNLINK").arg(keys::POSTS).query(redis_conn)?;
for key in redis::cmd("KEYS")
.arg(format!("{}*", keys::POST))
.query::<Vec<String>>(redis_conn)?
{
redis::cmd("UNLINK").arg(key).query(redis_conn)?;
}
for key in redis::cmd("KEYS")
.arg(format!("{}*", keys::TAG_POSTS))
.query::<Vec<String>>(redis_conn)?
{
redis::cmd("UNLINK").arg(key).query(redis_conn)?;
}
Ok(())
}
pub fn cache_posts(
db_conn: &mut db::Connection,
redis_conn: &mut Connection,
) -> Result<Vec<web::templates::PostIndexPost>> {
if let Some(s) = redis::cmd("GET")
.arg(keys::POSTS)
.query::<Option<String>>(redis_conn)?
{
Ok(serde_json::from_str(&s)?)
} else {
let posts_cache: Vec<web::templates::PostIndexPost> = db::schema::posts::table
.select((
db::schema::posts::id,
db::schema::posts::name,
db::schema::posts::slug,
db::schema::posts::description,
db::schema::posts::published_at,
db::schema::posts::edited_at,
))
.filter(db::schema::posts::active)
.order(db::schema::posts::published_at.desc())
.load::<(i32, String, String, String, NaiveDate, Option<NaiveDate>)>(db_conn)?
.into_iter()
.map(
|p: (i32, String, String, String, NaiveDate, Option<NaiveDate>)| -> Result<_> {
let (id, name, slug, description, published_at, edited_at) = p;
let tags = web::get_tags_by_post(id, db_conn)?;
Ok(web::templates::PostIndexPost {
name,
slug,
description,
published_at,
edited_at,
tags,
})
},
)
.collect::<Result<_>>()?;
redis::cmd("SET")
.arg(keys::POSTS)
.arg(serde_json::to_string(&posts_cache)?)
.query(redis_conn)?;
redis::cmd("EXPIRE")
.arg(keys::POSTS)
.arg(config::CONFIG.cache_ttl)
.query(redis_conn)?;
Ok(posts_cache)
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Post {
pub name: String,
pub slug: String,
pub published_at: NaiveDate,
pub edited_at: Option<NaiveDate>,
pub tags: Vec<String>,
pub content: String,
}
pub fn cache_post(
id: i32,
db_conn: &mut db::Connection,
redis_conn: &mut Connection,
) -> Result<Post> {
let key = keys::post(id);
if let Some(s) = redis::cmd("GET")
.arg(&key)
.query::<Option<String>>(redis_conn)?
{
Ok(serde_json::from_str(&s)?)
} else {
let post = db::schema::posts::table
.filter(db::schema::posts::id.eq(id))
.first::<db::models::Post>(db_conn)?;
let post_cache = Post {
name: post.name,
slug: post.slug,
published_at: post.published_at,
edited_at: post.edited_at,
tags: web::get_tags_by_post(id, db_conn)?,
content: markdown::to_html(&post.content),
};
redis::cmd("SET")
.arg(&key)
.arg(serde_json::to_string(&post_cache)?)
.query(redis_conn)?;
redis::cmd("EXPIRE")
.arg(key)
.arg(config::CONFIG.cache_ttl)
.query(redis_conn)?;
Ok(post_cache)
}
}
pub fn cache_tag_posts(
id: i32,
db_conn: &mut db::Connection,
redis_conn: &mut Connection,
) -> Result<Vec<web::templates::PostIndexPost>> {
let key = keys::tag_posts(id);
if let Some(s) = redis::cmd("GET")
.arg(&key)
.query::<Option<String>>(redis_conn)?
{
Ok(serde_json::from_str(&s)?)
} else {
let post_tag_post_ids = db::schema::post_tags::table
.select(db::schema::post_tags::post_id)
.filter(db::schema::post_tags::tag_id.eq(id))
.load::<i32>(db_conn)?;
let posts: Vec<web::templates::PostIndexPost> = db::schema::posts::table
.select((
db::schema::posts::id,
db::schema::posts::name,
db::schema::posts::slug,
db::schema::posts::description,
db::schema::posts::published_at,
db::schema::posts::edited_at,
))
.filter(db::schema::posts::id.eq_any(post_tag_post_ids))
.filter(db::schema::posts::active)
.order(db::schema::posts::published_at.desc())
.load::<(i32, String, String, String, NaiveDate, Option<NaiveDate>)>(db_conn)?
.into_iter()
.map(
|p: (i32, String, String, String, NaiveDate, Option<NaiveDate>)| -> Result<_> {
let (id, name, slug, description, published_at, edited_at) = p;
let tags = web::get_tags_by_post(id, db_conn)?;
Ok(web::templates::PostIndexPost {
name,
slug,
description,
published_at,
edited_at,
tags,
})
},
)
.collect::<Result<_>>()?;
redis::cmd("SET")
.arg(&key)
.arg(serde_json::to_string(&posts)?)
.query(redis_conn)?;
redis::cmd("EXPIRE")
.arg(key)
.arg(config::CONFIG.cache_ttl)
.query(redis_conn)?;
Ok(posts)
} }
} }

View file

@ -12,8 +12,8 @@ pub struct Config {
#[envconfig(from = "BACKEND_REDIS_URL")] #[envconfig(from = "BACKEND_REDIS_URL")]
pub redis_url: String, pub redis_url: String,
#[envconfig(from = "BACKEND_CACHE_POST_CONTENT_TTL")] #[envconfig(from = "BACKEND_CACHE_TTL")]
pub cache_post_content_ttl: usize, pub cache_ttl: usize,
} }
lazy_static! { lazy_static! {

View file

@ -10,8 +10,11 @@ pub mod models;
pub mod schema; pub mod schema;
pub type DbPool = Pool<ConnectionManager<PgConnection>>; pub type DbPool = Pool<ConnectionManager<PgConnection>>;
pub type Connection = PgConnection;
pub fn establish_connection() -> ConnectionResult<PgConnection> { pub fn establish_connection() -> ConnectionResult<PgConnection> {
use diesel::Connection;
PgConnection::establish(&config::CONFIG.db_url) PgConnection::establish(&config::CONFIG.db_url)
} }

View file

@ -52,3 +52,20 @@ pub struct UpdatePost<'a> {
pub edited_at: Option<Option<NaiveDate>>, pub edited_at: Option<Option<NaiveDate>>,
pub active: Option<bool>, pub active: Option<bool>,
} }
#[derive(Associations, Identifiable, Queryable, Debug)]
#[diesel(belongs_to(Post))]
#[diesel(belongs_to(Tag))]
#[diesel(table_name = schema::post_tags)]
pub struct PostTag {
pub id: i32,
pub post_id: i32,
pub tag_id: i32,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::post_tags)]
pub struct NewPostTag {
pub post_id: i32,
pub tag_id: i32,
}

View file

@ -17,3 +17,11 @@ diesel::table! {
active -> Bool, active -> Bool,
} }
} }
diesel::table! {
post_tags {
id -> Integer,
post_id -> Integer,
tag_id -> Integer,
}
}

View file

@ -1,11 +1,10 @@
use actix_web::{get, http, web, HttpResponse}; use actix_web::{get, http, web, HttpResponse};
use actix_web_static_files::ResourceFiles; use actix_web_static_files::ResourceFiles;
use anyhow::Result;
use askama_actix::TemplateToResponse; use askama_actix::TemplateToResponse;
use diesel::prelude::*; use diesel::prelude::*;
use r2d2_redis::redis;
use std::ops::DerefMut;
use crate::{cache, config, db, markdown}; use crate::{cache, db};
pub mod templates; pub mod templates;
@ -24,27 +23,6 @@ async fn not_found() -> HttpResponse {
resp 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("/")] #[get("/")]
async fn index() -> HttpResponse { async fn index() -> HttpResponse {
templates::Index.to_response() templates::Index.to_response()
@ -55,6 +33,43 @@ async fn about() -> HttpResponse {
templates::About.to_response() templates::About.to_response()
} }
pub fn get_tags_by_post(id: i32, db_conn: &mut diesel::PgConnection) -> Result<Vec<String>> {
Ok(db::schema::tags::table
.select(db::schema::tags::name)
.filter(
db::schema::tags::id.eq_any(
db::schema::post_tags::table
.select(db::schema::post_tags::tag_id)
.filter(db::schema::post_tags::post_id.eq(id))
.load::<i32>(db_conn)?,
),
)
.order(db::schema::tags::name)
.load::<String>(db_conn)?)
}
#[get("/posts")]
async fn posts(
db_pool: web::Data<db::DbPool>,
redis_pool: web::Data<cache::RedisPool>,
) -> HttpResponse {
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 posts = match cache::cache_posts(db_conn, redis_conn) {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
templates::Posts { posts }.to_response()
}
#[get("/posts/{slug}")] #[get("/posts/{slug}")]
async fn post_by_slug( async fn post_by_slug(
db_pool: web::Data<db::DbPool>, db_pool: web::Data<db::DbPool>,
@ -72,89 +87,181 @@ async fn post_by_slug(
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)), Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
}; };
let post_stripped: Option<(i32, String)> = match db::schema::posts::table if let Some(post_id) = match db::schema::posts::table
.select((db::schema::posts::id, db::schema::posts::name)) .select(db::schema::posts::id)
.filter(db::schema::posts::slug.eq(&slug)) .filter(db::schema::posts::slug.eq(&slug))
.filter(db::schema::posts::active) .filter(db::schema::posts::active)
.get_result::<(i32, String)>(db_conn) .first::<i32>(db_conn)
.optional() .optional()
{ {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
return HttpResponse::InternalServerError().body(format!("{:?}", e)); } {
let post = match cache::cache_post(post_id, db_conn, redis_conn) {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
templates::PostBySlug { post }.to_response()
} else {
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;
return resp;
}
// let post_stripped: Option<(i32, String, NaiveDate, Option<NaiveDate>)> =
// match db::schema::posts::table
// .select((
// db::schema::posts::id,
// db::schema::posts::name,
// db::schema::posts::published_at,
// db::schema::posts::edited_at,
// ))
// .filter(db::schema::posts::slug.eq(&slug))
// .filter(db::schema::posts::active)
// .first::<(i32, String, NaiveDate, Option<NaiveDate>)>(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_published_at, stripped_edited_at) = 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) => {
// let tags = match get_tags_by_post(stripped_id, db_conn) {
// Ok(x) => x,
// Err(e) => {
// return HttpResponse::InternalServerError().body(format!("{:?}", e))
// }
// };
// templates::PostBySlug {
// name: stripped_name,
// slug,
// published_at: stripped_published_at,
// edited_at: stripped_edited_at,
// tags,
// 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_ttl)
// .query::<()>(redis_conn.deref_mut())
// {
// return HttpResponse::InternalServerError().body(format!("{:?}", e));
// }
// let tags = match get_tags_by_post(stripped_id, db_conn) {
// Ok(x) => x,
// Err(e) => {
// return HttpResponse::InternalServerError().body(format!("{:?}", e))
// }
// };
// templates::PostBySlug {
// name: post.name,
// slug: post.slug,
// published_at: stripped_published_at,
// edited_at: stripped_edited_at,
// tags,
// 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
// }
// }
}
#[get("/tags/{name}")]
async fn tag_by_name(
db_pool: web::Data<db::DbPool>,
redis_pool: web::Data<cache::RedisPool>,
path: web::Path<String>,
) -> HttpResponse {
const MESSAGE: &str = "this post does not exists... yet";
let name = 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)),
}; };
match post_stripped { let tag_id = match db::schema::tags::table
Some(stripped) => { .select(db::schema::tags::id)
let (stripped_id, stripped_name) = stripped; .filter(db::schema::tags::name.eq(&name))
.first::<i32>(db_conn)
{
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let posts_cache = match cache::cache_tag_posts(tag_id, db_conn, redis_conn) {
Ok(x) => x,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let key = cache::keys::post_content(stripped_id); templates::TagByName {
name,
match match redis::cmd("GET") posts: posts_cache,
.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
}
} }
.to_response()
} }
fn setup_routes(cfg: &mut web::ServiceConfig) { fn setup_routes(cfg: &mut web::ServiceConfig) {
@ -165,6 +272,7 @@ fn setup_routes(cfg: &mut web::ServiceConfig) {
.service(posts) .service(posts)
.service(ResourceFiles::new("/static", generated)) .service(ResourceFiles::new("/static", generated))
.service(post_by_slug) .service(post_by_slug)
.service(tag_by_name)
.default_service(web::route().to(not_found)); .default_service(web::route().to(not_found));
} }

View file

@ -1,7 +1,9 @@
use actix_web::http; use actix_web::http;
use askama_actix::Template; use askama_actix::Template;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use crate::db; use crate::cache;
#[derive(Template)] #[derive(Template)]
#[template(path = "status_code.html")] #[template(path = "status_code.html")]
@ -18,16 +20,37 @@ pub struct Index;
#[template(path = "web/about.html")] #[template(path = "web/about.html")]
pub struct About; pub struct About;
#[derive(Serialize, Deserialize, Debug)]
pub struct PostIndexPost {
pub name: String,
pub slug: String,
pub description: String,
pub published_at: NaiveDate,
pub edited_at: Option<NaiveDate>,
pub tags: Vec<String>,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "web/posts/index.html")] #[template(path = "web/posts/index.html")]
pub struct Posts { pub struct Posts {
pub posts: Vec<db::models::Post>, pub posts: Vec<PostIndexPost>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "web/posts/{slug}.html")] #[template(path = "web/posts/{slug}.html")]
pub struct PostBySlug { pub struct PostBySlug {
pub name: String, // pub name: String,
pub slug: String, // pub slug: String,
pub content: String, // pub published_at: NaiveDate,
// pub edited_at: Option<NaiveDate>,
// pub tags: Vec<String>,
// pub content: String,
pub post: cache::Post,
}
#[derive(Template)]
#[template(path = "web/tags/{name}.html")]
pub struct TagByName {
pub name: String,
pub posts: Vec<PostIndexPost>,
} }

View file

@ -10,5 +10,5 @@
<h1>This is my site</h1> <h1>This is my site</h1>
<p>Hey, I'm Dominic (aka. dergrimm) and this is my blog!</p> <p>Hey, I'm Dominic (aka. dergrimm) and this is my blog!</p>
<p>I mostly plan to talk about modern tech and server side stuff like Rust, Crystal, C, databases and APIs.</p> <p>I mostly plan to talk about modern tech and server side stuff like Rust, Crystal, C, databases and APIs.</p>
<p>Keep posted by following my feed.</p> <p>Keep posted by following my (not yet existing) feed.</p>
{% endblock %} {% endblock %}

View file

@ -9,7 +9,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<ul id="post-index" class="dashed"> <ul class="post-index dashed">
{% for post in posts %} {% for post in posts %}
<li> <li>
<span> <span>
@ -17,6 +17,12 @@
(<i>{{ post.published_at }}{% match post.edited_at %}{% when Some with (x) %} -> {{ x }}{% when None %}{% endmatch %}</i>) (<i>{{ post.published_at }}{% match post.edited_at %}{% when Some with (x) %} -> {{ x }}{% when None %}{% endmatch %}</i>)
</span> </span>
<br /> <br />
<ul class="tag-list">
{% for tag in post.tags %}
<li><a href="/tags/{{ tag }}">{{ tag }}</a></li>
{% endfor %}
</ul>
<br />
<span>{{ post.description }}</span> <span>{{ post.description }}</span>
</li> </li>
{% endfor %} {% endfor %}

View file

@ -1,16 +1,40 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ name }}{% endblock %} {% block title %}{{ post.name }}{% endblock %}
{% block head %}{% endblock %} {% block head %}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<li><a href="/posts">posts</a></li> <li><a href="/posts">posts</a></li>
<li><a href="/posts/{{ slug }}">{{ slug }}</a></li> <li><a href="/posts/{{ post.slug }}">{{ post.slug }}</a></li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<table id="blog-meta">
<tr>
<th>Published at</th>
<td>{{ post.published_at }}</td>
</tr>
{% match post.edited_at %}
{% when Some with (x) %}
<tr>
<th>Edited at</th>
<td>{{ x }}</td>
</tr>
{% when None %}
{% endmatch %}
<tr>
<th>Tags</th>
<td>
<ul class="tag-list">
{% for tag in post.tags %}
<li><a href="/tags/{{ tag }}">{{ tag }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
</table>
<article class="wysiwyg"> <article class="wysiwyg">
{{content|safe }} {{ post.content|safe }}
</article> </article>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ name }}{% endblock %}
{% block head %}{% endblock %}
{% block breadcrumb %}
<li><a href="/tags">tags</a></li>
<li><a href="/tags/{{ name }}">{{ name }}</a></li>
{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
<ul class="post-index dashed">
{% for post in posts %}
<li>
<span>
<a href="/posts/{{ post.slug }}">{{ post.name }}</a>
(<i>{{ post.published_at }}{% match post.edited_at %}{% when Some with (x) %} -> {{ x }}{% when None %}{% endmatch %}</i>)
</span>
<br />
<ul class="tag-list">
{% for tag in post.tags %}
<li><a href="/tags/{{ tag }}">{{ tag }}</a></li>
{% endfor %}
</ul>
<br />
<span>{{ post.description }}</span>
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -21,6 +21,8 @@ services:
redis: redis:
image: docker.io/redis:7-alpine image: docker.io/redis:7-alpine
restart: always restart: always
volumes:
- redis:/data
redis-commander: redis-commander:
image: rediscommander/redis-commander:latest image: rediscommander/redis-commander:latest
@ -42,7 +44,7 @@ services:
BACKEND_BIND_URL: 0.0.0.0:80 BACKEND_BIND_URL: 0.0.0.0:80
BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER} BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER}
BACKEND_REDIS_URL: redis://redis BACKEND_REDIS_URL: redis://redis
BACKEND_CACHE_POST_CONTENT_TTL: 3600 BACKEND_CACHE_TTL: 3600
volumes: volumes:
- ./blog:/blog - ./blog:/blog
ports: ports:
@ -53,3 +55,4 @@ services:
volumes: volumes:
db: db:
redis: