From 964534d0d9dd0cf6cddb88934fbbc921abbaf049 Mon Sep 17 00:00:00 2001 From: Dominic Grimm Date: Sun, 12 Feb 2023 09:18:56 +0100 Subject: [PATCH] Update --- .editorconfig | 8 + Makefile | 5 +- backend/Cargo.lock | 21 +- backend/Cargo.toml | 2 + backend/Dockerfile | 7 +- .../2023-02-06-134456_init/down.sql | 4 +- .../migrations/2023-02-06-134456_init/up.sql | 8 +- backend/scss/styles.scss | 43 ++- backend/src/bin/blogctl.rs | 223 ++++++++----- backend/src/cache.rs | 204 +++++++++++- backend/src/config.rs | 4 +- backend/src/db/mod.rs | 3 + backend/src/db/models.rs | 17 + backend/src/db/schema.rs | 8 + backend/src/web/mod.rs | 302 ++++++++++++------ backend/src/web/templates.rs | 33 +- backend/templates/web/index.html | 2 +- backend/templates/web/posts/index.html | 8 +- backend/templates/web/posts/{slug}.html | 30 +- backend/templates/web/tags/{name}.html | 32 ++ docker-compose.yml | 5 +- 21 files changed, 762 insertions(+), 207 deletions(-) create mode 100644 backend/templates/web/tags/{name}.html diff --git a/.editorconfig b/.editorconfig index b072741..cf4f643 100644 --- a/.editorconfig +++ b/.editorconfig @@ -39,3 +39,11 @@ insert_final_newline = true indent_style = space indent_size = 4 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 diff --git a/Makefile b/Makefile index c7c6c67..597c21a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ -.PHONY: all build +.PHONY: all build ci all: build build: + docker compose build + +ci: BUILDKIT_PROGRESS=plain docker compose build diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bdbdcdf..cb19589 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -355,11 +355,13 @@ dependencies = [ "env_logger", "envconfig", "fronma", + "itertools", "lazy_static", "log", "r2d2_redis", "scan_dir", "serde", + "serde_json", "serde_yaml 0.9.17", "static-files", "tikv-jemallocator", @@ -777,6 +779,12 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + [[package]] name = "emojis" version = "0.5.2" @@ -1111,6 +1119,15 @@ dependencies = [ "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]] name = "itoa" version = "0.4.8" @@ -1784,9 +1801,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a" +checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ "itoa 1.0.5", "ryu", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9fd6ffd..8e0beeb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -33,11 +33,13 @@ diesel = { version = "2.0.2", features = ["i-implement-a-third-party-backend-and env_logger = "0.10.0" envconfig = "0.10.0" fronma = "0.1.1" +itertools = "0.10.5" lazy_static = "1.4.0" log = "0.4.17" r2d2_redis = "0.14.0" scan_dir = "0.3.3" serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.93" serde_yaml = "0.9.17" static-files = "0.2.3" diff --git a/backend/Dockerfile b/backend/Dockerfile index 75619c8..4234017 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,13 +29,13 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef as builder WORKDIR /usr/src/backend 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 COPY ./build.rs . COPY --from=static /usr/src/static ./static COPY --from=templates /usr/src/templates ./templates COPY ./src ./src -RUN cargo build +RUN cargo build --release FROM docker.io/debian:bullseye-slim as runner LABEL maintainer="Dominic Grimm " \ @@ -45,7 +45,6 @@ LABEL maintainer="Dominic Grimm " \ org.opencontainers.image.url="https://git.dergrimm.net/dergrimm/blog" RUN apt update RUN apt install -y libpq5 -RUN apt install -y ca-certificates RUN apt-get clean RUN apt-get autoremove -y RUN rm -rf /var/lib/{apt,dpkg,cache,log}/ @@ -55,5 +54,5 @@ WORKDIR /usr/src/backend COPY ./run.sh . RUN chmod +x ./run.sh 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" ] diff --git a/backend/migrations/2023-02-06-134456_init/down.sql b/backend/migrations/2023-02-06-134456_init/down.sql index 271ceaa..4ab2e2b 100644 --- a/backend/migrations/2023-02-06-134456_init/down.sql +++ b/backend/migrations/2023-02-06-134456_init/down.sql @@ -1,3 +1,5 @@ +DROP TABLE post_tags; + DROP TABLE posts; -DROP TABLE tags; \ No newline at end of file +DROP TABLE tags; diff --git a/backend/migrations/2023-02-06-134456_init/up.sql b/backend/migrations/2023-02-06-134456_init/up.sql index 2fa4c6f..ac7c97f 100644 --- a/backend/migrations/2023-02-06-134456_init/up.sql +++ b/backend/migrations/2023-02-06-134456_init/up.sql @@ -12,4 +12,10 @@ CREATE TABLE posts( published_at DATE NOT NULL, edited_at DATE, active BOOLEAN NOT NULL -); \ No newline at end of file +); + +CREATE TABLE post_tags( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES posts(id), + tag_id INTEGER NOT NULL REFERENCES tags(id) +); diff --git a/backend/scss/styles.scss b/backend/scss/styles.scss index 6a2541b..5446a6a 100644 --- a/backend/scss/styles.scss +++ b/backend/scss/styles.scss @@ -45,7 +45,7 @@ hr { ul.dashed { list-style-type: none; - li:before { + & > li:before { content: "-"; position: absolute; // margin-left: -20px; @@ -107,7 +107,7 @@ table.border-rows { list-style-type: none; margin: 0; padding: 0; - overflow: hidden; + // overflow: hidden; float: right; li { @@ -138,9 +138,37 @@ table.border-rows { font-size: small; } -#post-index { - li:not(:last-child) { - margin-bottom: 1rem; +ul.post-index > li:not(:last-child) { + 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; overflow-x: scroll; } + + blockquote { + padding: 0 1rem; + border-left: medium solid black; + } } @media only screen and (max-width: 600px) { diff --git a/backend/src/bin/blogctl.rs b/backend/src/bin/blogctl.rs index fc78ab3..f987ac4 100644 --- a/backend/src/bin/blogctl.rs +++ b/backend/src/bin/blogctl.rs @@ -9,7 +9,7 @@ use anyhow::{bail, Result}; use chrono::prelude::*; use clap::{Parser, Subcommand}; use diesel::prelude::*; -use r2d2_redis::redis; +use itertools::Itertools; use scan_dir::ScanDir; use serde::{Deserialize, Serialize}; use std::fs; @@ -33,12 +33,13 @@ enum Commands { Clear, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug)] struct PostFrontmatter { id: Option, name: String, slug: String, description: String, + tags: Vec, published_at: NaiveDate, edited_at: Option, active: bool, @@ -57,9 +58,9 @@ fn main() -> Result<()> { let db_conn = &mut db::establish_connection()?; let redis_conn = &mut cache::establish_connection()?; - let posts = ScanDir::dirs() - .read("/blog", |iter| { - iter.map(|(entry, _)| { + let (tags_raw, posts_raw) = ScanDir::dirs().read("/blog", |iter| -> Result<_> { + let x = iter + .map(|(entry, _)| { let path = entry.path().join("post.md"); let src = fs::read_to_string(&path)?; let frontmatter = match fronma::parser::parse::(&src) { @@ -67,54 +68,74 @@ fn main() -> Result<()> { Err(x) => bail!("Error parsing frontmatter: {:?}", x), }; - Ok(Post { - path, - frontmatter: frontmatter.headers, - content: frontmatter.body.to_string(), - }) + Ok(( + frontmatter.headers.tags.to_owned(), + Post { + path, + frontmatter: frontmatter.headers, + content: frontmatter.body.to_string(), + }, + )) }) - .collect::>>() - })?? + .collect::, Post)>>>()?; + + let tags: Vec = x.iter().flat_map(|y| y.0.to_owned()).unique().collect(); + let posts: Vec = x.into_iter().map(|y| y.1).collect(); + + Ok((tags, posts)) + })??; + + let tags = tags_raw .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::(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::(db_conn)?, + }, + ) + }) + .collect::>>()?; + 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 { 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(), + tags: post + .frontmatter + .tags + .iter() + .map(|x| x.trim().to_string()) + .collect(), 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(&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::(db_conn) - .optional()? - { + let id = { + let res: Result = 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(&trimmed.name), - slug: None, + slug: Some(&trimmed.slug), description: Some(&trimmed.description), content: Some(content), published_at: Some(trimmed.published_at), @@ -123,69 +144,111 @@ fn main() -> Result<()> { }) .execute(db_conn)?; - id + Ok(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::(db_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::(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)?; + + 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::(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( - post.path, - format!( - "---\n{}---\n\n{}\n", - serde_yaml::to_string(&PostFrontmatter { - id: Some(id), - ..trimmed - })?, - content - ), - )?; + res + }?; - Ok(id) - } + let tag_ids = db::schema::tags::table + .select(db::schema::tags::id) + .filter(db::schema::tags::name.eq_any(trimmed.tags)) + .load::(db_conn)?; + + let post_tags = db::schema::post_tags::table + .filter(db::schema::post_tags::post_id.eq(id)) + .load::(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::>(), + ) + .execute(db_conn)?; + + Ok(id) }) .collect::>>()?; - let ids = db::schema::posts::table - .select(db::schema::posts::id) - .load::(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)?; - } + cache::clear(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::(db_conn)? - { - redis::cmd("DEL") - .arg(cache::keys::post_content(id)) - .query::<()>(redis_conn)?; - } + cache::clear(redis_conn)?; Ok(()) } diff --git a/backend/src/cache.rs b/backend/src/cache.rs index 5fe7070..185983f 100644 --- a/backend/src/cache.rs +++ b/backend/src/cache.rs @@ -1,11 +1,15 @@ use anyhow::Result; +use chrono::prelude::*; +use diesel::prelude::*; use lazy_static::lazy_static; use r2d2_redis::{r2d2, redis, RedisConnectionManager}; +use serde::{Deserialize, Serialize}; -use crate::config; +use crate::{config, db, markdown, web}; pub type RedisPool = r2d2::Pool; pub type ConnectionPool = r2d2::PooledConnection; +pub type Connection = redis::Connection; pub fn establish_connection() -> Result { Ok(redis::Client::open(config::CONFIG.redis_url.as_str())?.get_connection()?) @@ -22,7 +26,201 @@ lazy_static! { } pub mod keys { - pub fn post_content(id: i32) -> String { - format!("post_content:{}", id) + pub const POSTS: &str = "posts"; + + 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::>(redis_conn)? + { + redis::cmd("UNLINK").arg(key).query(redis_conn)?; + } + + for key in redis::cmd("KEYS") + .arg(format!("{}*", keys::TAG_POSTS)) + .query::>(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> { + if let Some(s) = redis::cmd("GET") + .arg(keys::POSTS) + .query::>(redis_conn)? + { + Ok(serde_json::from_str(&s)?) + } else { + let posts_cache: Vec = 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)>(db_conn)? + .into_iter() + .map( + |p: (i32, String, String, String, NaiveDate, Option)| -> 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::>()?; + + 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, + pub tags: Vec, + pub content: String, +} + +pub fn cache_post( + id: i32, + db_conn: &mut db::Connection, + redis_conn: &mut Connection, +) -> Result { + let key = keys::post(id); + + if let Some(s) = redis::cmd("GET") + .arg(&key) + .query::>(redis_conn)? + { + Ok(serde_json::from_str(&s)?) + } else { + let post = db::schema::posts::table + .filter(db::schema::posts::id.eq(id)) + .first::(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> { + let key = keys::tag_posts(id); + + if let Some(s) = redis::cmd("GET") + .arg(&key) + .query::>(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::(db_conn)?; + + let posts: Vec = 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)>(db_conn)? + .into_iter() + .map( + |p: (i32, String, String, String, NaiveDate, Option)| -> 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::>()?; + + 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) } } diff --git a/backend/src/config.rs b/backend/src/config.rs index 282d108..b1afe61 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -12,8 +12,8 @@ pub struct Config { #[envconfig(from = "BACKEND_REDIS_URL")] pub redis_url: String, - #[envconfig(from = "BACKEND_CACHE_POST_CONTENT_TTL")] - pub cache_post_content_ttl: usize, + #[envconfig(from = "BACKEND_CACHE_TTL")] + pub cache_ttl: usize, } lazy_static! { diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 53fa54b..63ff31e 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -10,8 +10,11 @@ pub mod models; pub mod schema; pub type DbPool = Pool>; +pub type Connection = PgConnection; pub fn establish_connection() -> ConnectionResult { + use diesel::Connection; + PgConnection::establish(&config::CONFIG.db_url) } diff --git a/backend/src/db/models.rs b/backend/src/db/models.rs index a004047..b186d15 100644 --- a/backend/src/db/models.rs +++ b/backend/src/db/models.rs @@ -52,3 +52,20 @@ pub struct UpdatePost<'a> { pub edited_at: Option>, pub active: Option, } + +#[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, +} diff --git a/backend/src/db/schema.rs b/backend/src/db/schema.rs index f0293db..e2e8f73 100644 --- a/backend/src/db/schema.rs +++ b/backend/src/db/schema.rs @@ -17,3 +17,11 @@ diesel::table! { active -> Bool, } } + +diesel::table! { + post_tags { + id -> Integer, + post_id -> Integer, + tag_id -> Integer, + } +} diff --git a/backend/src/web/mod.rs b/backend/src/web/mod.rs index 0b092f5..0cad2ee 100644 --- a/backend/src/web/mod.rs +++ b/backend/src/web/mod.rs @@ -1,11 +1,10 @@ use actix_web::{get, http, web, HttpResponse}; use actix_web_static_files::ResourceFiles; +use anyhow::Result; use askama_actix::TemplateToResponse; use diesel::prelude::*; -use r2d2_redis::redis; -use std::ops::DerefMut; -use crate::{cache, config, db, markdown}; +use crate::{cache, db}; pub mod templates; @@ -24,27 +23,6 @@ async fn not_found() -> HttpResponse { resp } -#[get("/posts")] -async fn posts(db_pool: web::Data) -> 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_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() @@ -55,6 +33,43 @@ async fn about() -> HttpResponse { templates::About.to_response() } +pub fn get_tags_by_post(id: i32, db_conn: &mut diesel::PgConnection) -> Result> { + 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::(db_conn)?, + ), + ) + .order(db::schema::tags::name) + .load::(db_conn)?) +} + +#[get("/posts")] +async fn posts( + db_pool: web::Data, + redis_pool: web::Data, +) -> 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}")] async fn post_by_slug( db_pool: web::Data, @@ -72,89 +87,181 @@ async fn post_by_slug( 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)) + if let Some(post_id) = match db::schema::posts::table + .select(db::schema::posts::id) .filter(db::schema::posts::slug.eq(&slug)) .filter(db::schema::posts::active) - .get_result::<(i32, String)>(db_conn) + .first::(db_conn) .optional() { Ok(x) => x, - Err(e) => { - return HttpResponse::InternalServerError().body(format!("{:?}", e)); + Err(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)> = + // 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)>(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::>(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_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::>(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, + redis_pool: web::Data, + path: web::Path, +) -> 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 { - Some(stripped) => { - let (stripped_id, stripped_name) = stripped; + let tag_id = match db::schema::tags::table + .select(db::schema::tags::id) + .filter(db::schema::tags::name.eq(&name)) + .first::(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); - - match match redis::cmd("GET") - .arg(&key) - .query::>(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_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::>(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 - } + templates::TagByName { + name, + posts: posts_cache, } + .to_response() } fn setup_routes(cfg: &mut web::ServiceConfig) { @@ -165,6 +272,7 @@ fn setup_routes(cfg: &mut web::ServiceConfig) { .service(posts) .service(ResourceFiles::new("/static", generated)) .service(post_by_slug) + .service(tag_by_name) .default_service(web::route().to(not_found)); } diff --git a/backend/src/web/templates.rs b/backend/src/web/templates.rs index 2abe2c2..1b1e583 100644 --- a/backend/src/web/templates.rs +++ b/backend/src/web/templates.rs @@ -1,7 +1,9 @@ use actix_web::http; use askama_actix::Template; +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; -use crate::db; +use crate::cache; #[derive(Template)] #[template(path = "status_code.html")] @@ -18,16 +20,37 @@ pub struct Index; #[template(path = "web/about.html")] 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, + pub tags: Vec, +} + #[derive(Template)] #[template(path = "web/posts/index.html")] pub struct Posts { - pub posts: Vec, + pub posts: Vec, } #[derive(Template)] #[template(path = "web/posts/{slug}.html")] pub struct PostBySlug { - pub name: String, - pub slug: String, - pub content: String, + // pub name: String, + // pub slug: String, + // pub published_at: NaiveDate, + // pub edited_at: Option, + // pub tags: Vec, + // pub content: String, + pub post: cache::Post, +} + +#[derive(Template)] +#[template(path = "web/tags/{name}.html")] +pub struct TagByName { + pub name: String, + pub posts: Vec, } diff --git a/backend/templates/web/index.html b/backend/templates/web/index.html index 36f1e74..df7c55a 100644 --- a/backend/templates/web/index.html +++ b/backend/templates/web/index.html @@ -10,5 +10,5 @@

This is my site

Hey, I'm Dominic (aka. dergrimm) and this is my blog!

I mostly plan to talk about modern tech and server side stuff like Rust, Crystal, C, databases and APIs.

-

Keep posted by following my feed.

+

Keep posted by following my (not yet existing) feed.

{% endblock %} diff --git a/backend/templates/web/posts/index.html b/backend/templates/web/posts/index.html index 3a6a9c1..4b22012 100644 --- a/backend/templates/web/posts/index.html +++ b/backend/templates/web/posts/index.html @@ -9,7 +9,7 @@ {% endblock %} {% block content %} -
    +
      {% for post in posts %}
    • @@ -17,6 +17,12 @@ ({{ post.published_at }}{% match post.edited_at %}{% when Some with (x) %} -> {{ x }}{% when None %}{% endmatch %})
      +
        + {% for tag in post.tags %} +
      • {{ tag }}
      • + {% endfor %} +
      +
      {{ post.description }}
    • {% endfor %} diff --git a/backend/templates/web/posts/{slug}.html b/backend/templates/web/posts/{slug}.html index 1f1754d..db18401 100644 --- a/backend/templates/web/posts/{slug}.html +++ b/backend/templates/web/posts/{slug}.html @@ -1,16 +1,40 @@ {% extends "base.html" %} -{% block title %}{{ name }}{% endblock %} +{% block title %}{{ post.name }}{% endblock %} {% block head %}{% endblock %} {% block breadcrumb %}
    • posts
    • -
    • {{ slug }}
    • +
    • {{ post.slug }}
    • {% endblock %} {% block content %} + + + + + + {% match post.edited_at %} + {% when Some with (x) %} + + + + + {% when None %} + {% endmatch %} + + + + +
      Published at{{ post.published_at }}
      Edited at{{ x }}
      Tags +
        + {% for tag in post.tags %} +
      • {{ tag }}
      • + {% endfor %} +
      +
      - {{content|safe }} + {{ post.content|safe }}
      {% endblock %} diff --git a/backend/templates/web/tags/{name}.html b/backend/templates/web/tags/{name}.html new file mode 100644 index 0000000..d041f3a --- /dev/null +++ b/backend/templates/web/tags/{name}.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %}{{ name }}{% endblock %} + +{% block head %}{% endblock %} + +{% block breadcrumb %} +
    • tags
    • +
    • {{ name }}
    • +{% endblock %} + +{% block content %} +

      {{ name }}

      +
        + {% for post in posts %} +
      • + + {{ post.name }} + ({{ post.published_at }}{% match post.edited_at %}{% when Some with (x) %} -> {{ x }}{% when None %}{% endmatch %}) + +
        +
          + {% for tag in post.tags %} +
        • {{ tag }}
        • + {% endfor %} +
        +
        + {{ post.description }} +
      • + {% endfor %} +
      +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 5fd4c0c..9f82e3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: redis: image: docker.io/redis:7-alpine restart: always + volumes: + - redis:/data redis-commander: image: rediscommander/redis-commander:latest @@ -42,7 +44,7 @@ services: BACKEND_BIND_URL: 0.0.0.0:80 BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER} BACKEND_REDIS_URL: redis://redis - BACKEND_CACHE_POST_CONTENT_TTL: 3600 + BACKEND_CACHE_TTL: 3600 volumes: - ./blog:/blog ports: @@ -53,3 +55,4 @@ services: volumes: db: + redis: