diff --git a/Cargo.lock b/Cargo.lock index 82e5509..bea0005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "ammonia" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e6d1c7838db705c9b756557ee27c384ce695a1c51a6fe528784cb1c6840170" +dependencies = [ + "html5ever", + "maplit", + "once_cell", + "tendril", + "url", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -603,7 +616,7 @@ checksum = "f1369bc6b9e9a7dfdae2055f6ec151fe9c554a9d23d357c0237cee2e25eaabb7" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.2", "serde", ] @@ -614,8 +627,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2f5ebdc942f57ed96d560a6d1a459bae5851102a25d5bf89dc04ae453e31ecf" dependencies = [ "parse-zoneinfo", - "phf", - "phf_codegen", + "phf 0.11.2", + "phf_codegen 0.11.2", ] [[package]] @@ -1355,6 +1368,16 @@ dependencies = [ "url", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.28" @@ -1605,6 +1628,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "http" version = "0.2.9" @@ -1927,6 +1964,32 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2055,6 +2118,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + [[package]] name = "nom" version = "7.1.3" @@ -2248,13 +2317,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + [[package]] name = "phf" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_shared", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", ] [[package]] @@ -2263,8 +2351,18 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", ] [[package]] @@ -2273,10 +2371,19 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" dependencies = [ - "phf_shared", + "phf_shared 0.11.2", "rand 0.8.5", ] +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.11.2" @@ -2364,6 +2471,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "pretty_env_logger" version = "0.4.0" @@ -2417,6 +2530,7 @@ dependencies = [ name = "quanweb" version = "1.0.0" dependencies = [ + "ammonia", "async-fred-session", "async-trait", "atom_syndication", @@ -3180,6 +3294,32 @@ dependencies = [ "lock_api", ] +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.10.0" @@ -3306,6 +3446,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termcolor" version = "1.2.0" @@ -3948,6 +4099,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 56b7f11..6d1cba9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ default-run = "quanweb" strip="debuginfo" [dependencies] +ammonia = "3.3.0" async-fred-session = "0.1.5" async-trait = "0.1.71" atom_syndication = { version = "0.12.2", features = ["serde"] } diff --git a/src/front/routes.rs b/src/front/routes.rs index fd34e16..0e9b5bf 100644 --- a/src/front/routes.rs +++ b/src/front/routes.rs @@ -16,5 +16,6 @@ pub fn get_router() -> Router { .route("/talk/", get(views::minors::list_talks)) .route("/book/", get(views::minors::list_books)) .route("/feeds.atom", get(views::feeds::gen_atom_feeds)) + .route("/feeds.json", get(views::feeds::gen_json_feeds)) .route("/api/set-lang", post(views::set_lang)) } diff --git a/src/front/structs.rs b/src/front/structs.rs index f20ff4d..a2175a3 100644 --- a/src/front/structs.rs +++ b/src/front/structs.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroU16; use serde::Deserialize; @@ -11,6 +12,15 @@ pub struct LaxPaging { pub page: Option, } +impl LaxPaging { + /// Get the page as non-zero number (default to 1). + /// The type is NonZeroU16 because our website is small enough for page number + /// to be fit in u16. + pub fn get_page_as_number(&self) -> NonZeroU16 { + self.page.as_deref().map(|s| s.parse().ok()).flatten().unwrap_or(NonZeroU16::MIN) + } +} + #[derive(Debug, Clone, Deserialize)] pub struct SetLangReq { pub lang: String, diff --git a/src/front/views/blog.rs b/src/front/views/blog.rs index 0da26f1..46ae659 100644 --- a/src/front/views/blog.rs +++ b/src/front/views/blog.rs @@ -71,10 +71,7 @@ pub async fn list_posts( State(state): State, ) -> AxumResult> { let AppState { db, jinja } = state; - let current_page = paging - .page - .and_then(|p| NonZeroU16::new(p.parse().ok()?)) - .unwrap_or(NonZeroU16::MIN); + let current_page = paging.get_page_as_number(); let page_size = DEFAULT_PAGE_SIZE; let offset = ((current_page.get() - 1) * page_size as u16) as i64; let cat = stores::blog::get_category_by_slug(&cat_slug, &db) diff --git a/src/front/views/feeds.rs b/src/front/views/feeds.rs index 6d043a5..ff71426 100644 --- a/src/front/views/feeds.rs +++ b/src/front/views/feeds.rs @@ -2,7 +2,7 @@ use std::num::NonZeroU16; use atom_syndication::{Entry, FeedBuilder, LinkBuilder}; use axum::extract::{Query, State, Host, OriginalUri}; -use axum::response::{Result as AxumResult, IntoResponseParts}; +use axum::response::{Result as AxumResult, IntoResponseParts, Json}; use chrono::{TimeZone, Utc}; use edgedb_tokio::Client as EdgeClient; use http::header::CONTENT_TYPE; @@ -10,6 +10,7 @@ use http::header::CONTENT_TYPE; use super::super::structs::LaxPaging; use crate::consts::DEFAULT_PAGE_SIZE; use crate::errors::PageError; +use crate::models::feeds::{JsonFeed, JsonItem}; use crate::stores; use crate::types::Paginator; @@ -23,10 +24,7 @@ pub async fn gen_atom_feeds( State(db): State, ) -> AxumResult<(impl IntoResponseParts, String)> { let base_url = format!("https://{host}"); - let current_page = paging - .page - .and_then(|p| NonZeroU16::new(p.parse().ok()?)) - .unwrap_or(NonZeroU16::MIN); + let current_page = paging.get_page_as_number(); let page_size = DEFAULT_PAGE_SIZE; let offset = ((current_page.get() - 1) * page_size as u16) as i64; let posts = stores::blog::get_published_posts(Some(offset), Some(page_size as i64), &db) @@ -74,3 +72,43 @@ pub async fn gen_atom_feeds( .build(); Ok(([(CONTENT_TYPE, "application/atom+xml; charset=utf-8")], feed.to_string())) } + + +pub async fn gen_json_feeds( + Host(host): Host, + OriginalUri(current_url): OriginalUri, + Query(paging): Query, + State(db): State, +) -> AxumResult> { + let base_url = format!("https://{host}"); + let current_page = paging.get_page_as_number(); + let page_size = DEFAULT_PAGE_SIZE; + let offset = ((current_page.get() - 1) * page_size as u16) as i64; + let posts = stores::blog::get_published_posts(Some(offset), Some(page_size as i64), &db) + .await + .map_err(PageError::EdgeDBQueryError)?; + let total = stores::blog::count_all_published_posts(&db) + .await + .map_err(PageError::EdgeDBQueryError)?; + let total_pages = NonZeroU16::try_from((total as f64 / page_size as f64).ceil() as u16) + .unwrap_or(NonZeroU16::MIN); + let paginator = Paginator { + current_page, + total_pages, + }; + let next_page_url = paginator.next_url(¤t_url); + let mut feed = JsonFeed::default(); + feed.feed_url = Some(format!("{base_url}{current_url}")); + feed.next_url = next_page_url.map(|url| format!("{base_url}{url}")); + let mut items: Vec = posts.into_iter().map(JsonItem::from).collect(); + items.iter_mut().for_each(|it| { + match it.url { + Some(ref url) if url.starts_with("/") => { + it.url = Some(format!("{base_url}{url}")); + } + _ => {} + } + }); + feed.items = items; + Ok(Json(feed)) +} diff --git a/src/front/views/mod.rs b/src/front/views/mod.rs index 7d4d57c..c2337ff 100644 --- a/src/front/views/mod.rs +++ b/src/front/views/mod.rs @@ -42,10 +42,7 @@ pub async fn home( State(state): State, ) -> AxumResult> { let AppState { db, jinja } = state; - let current_page = paging - .page - .and_then(|p| NonZeroU16::new(p.parse().ok()?)) - .unwrap_or(NonZeroU16::MIN); + let current_page = paging.get_page_as_number(); let total = stores::blog::count_all_published_posts(&db) .await .map_err(PageError::EdgeDBQueryError)?; diff --git a/src/models/blogs.rs b/src/models/blogs.rs index a0be4de..384448a 100644 --- a/src/models/blogs.rs +++ b/src/models/blogs.rs @@ -15,6 +15,8 @@ use crate::types::conversions::{ serialize_edge_datetime, serialize_optional_edge_datetime, }; use super::users::MiniUser; +use super::feeds::{JsonAuthor, JsonItem}; +use crate::utils::html::strip_tags; #[derive( Debug, @@ -61,6 +63,7 @@ pub struct MediumBlogPost { pub id: Uuid, pub title: String, pub slug: String, + pub locale: Option, pub excerpt: Option, pub is_published: Option, pub published_at: Option, @@ -111,6 +114,7 @@ impl Default for MediumBlogPost { id: Uuid::default(), title: String::default(), slug: String::default(), + locale: None, excerpt: None, is_published: Some(false), published_at: None, @@ -161,6 +165,42 @@ impl From for AtomEntry { } } +impl From for JsonItem { + fn from(value: MediumBlogPost) -> Self { + let url = value.get_view_url(None); + let MediumBlogPost { + id, + title, + excerpt, + locale, + published_at, + created_at, + updated_at, + categories, + author, + .. + } = value; + let entry_id = format!("urn:uuid:{id}"); + let updated_at: DateTime = updated_at.unwrap_or(created_at).into(); + let categories: Vec = categories.into_iter().map(|c| c.title).collect(); + let authors = author.map(|a| vec![JsonAuthor::from(a)]); + JsonItem { + id: entry_id, + url: Some(url), + external_url: None, + title: Some(title), + content_html: None, + content_text: None, + summary: excerpt.as_deref().map(strip_tags), + date_published: published_at.map(|d| DateTime::::from(d).to_rfc3339()), + date_modified: Some(updated_at.to_rfc3339()), + authors, + tags: Some(categories), + language: locale, + } + } +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, Queryable)] pub struct BlogCategory { pub id: Uuid, diff --git a/src/models/feeds.rs b/src/models/feeds.rs new file mode 100644 index 0000000..5207529 --- /dev/null +++ b/src/models/feeds.rs @@ -0,0 +1,56 @@ +// Just copy from https://github.com/feed-rs/feed-rs/ + +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct JsonFeed { + pub version: String, + pub title: String, + pub home_page_url: Option, + pub feed_url: Option, + pub next_url: Option, + pub description: Option, + pub icon: Option, + pub favicon: Option, + pub authors: Option>, + pub items: Vec, +} + +impl Default for JsonFeed { + fn default() -> Self { + Self { + version: "https://jsonfeed.org/version/1.1".into(), + title: "QuanWeb".into(), + home_page_url: Some("https://quan.hoabinh.vn".into()), + feed_url: None, + next_url: None, + description: Some("Blog about programming, culture, history".into()), + icon: None, + favicon: None, + authors: None, + items: vec![], + } + } +} + +#[derive(Debug, Serialize)] +pub struct JsonAuthor { + pub name: Option, + pub url: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonItem { + pub id: String, + pub url: Option, + pub external_url: Option, + pub title: Option, + pub content_html: Option, + pub content_text: Option, + pub summary: Option, + pub date_published: Option, + pub date_modified: Option, + pub authors: Option>, + pub tags: Option>, + pub language: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 755a41d..071f988 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod users; pub mod blogs; pub mod minors; +pub mod feeds; pub use users::{User, Role}; pub use blogs::{DocFormat, MediumBlogPost, DetailedBlogPost, BlogCategory, MiniBlogPost}; diff --git a/src/models/users.rs b/src/models/users.rs index b95ba4f..271ac4d 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -5,6 +5,8 @@ use edgedb_derive::Queryable; use serde::{Serialize, Deserialize}; use atom_syndication::{Person, PersonBuilder}; +use super::feeds::JsonAuthor; + #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, Queryable)] pub struct User { pub id: Uuid, @@ -32,6 +34,15 @@ impl From for Person { } } +impl From for JsonAuthor { + fn from(user: MiniUser) -> Self { + JsonAuthor { + name: Some(user.username), + url: None, + } + } +} + #[derive(Debug, Clone, PartialEq, PartialOrd)] pub enum Role { Admin, diff --git a/src/stores/blog.rs b/src/stores/blog.rs index 28d687c..bba3e13 100644 --- a/src/stores/blog.rs +++ b/src/stores/blog.rs @@ -116,6 +116,7 @@ pub async fn get_blogposts(lower_search_tokens: Option<&Vec>, offset: Op id, title, slug, + locale, excerpt, is_published, published_at, @@ -162,6 +163,7 @@ pub async fn get_published_posts(offset: Option, limit: Option, client id, title, slug, + locale, excerpt, is_published, published_at, @@ -211,6 +213,7 @@ pub async fn get_published_posts_under_category(cat_slug: Option, offset id, title, slug, + locale, excerpt, is_published, published_at, @@ -260,6 +263,7 @@ pub async fn get_published_uncategorized_blogposts(offset: Option, limit: O id, title, slug, + locale, excerpt, is_published, published_at, diff --git a/src/utils/html.rs b/src/utils/html.rs new file mode 100644 index 0000000..63f27c0 --- /dev/null +++ b/src/utils/html.rs @@ -0,0 +1,13 @@ +use std::collections::HashSet; + +use ammonia::Builder; +use once_cell::sync::Lazy; + +pub fn strip_tags(html: &str) -> String { + let builder: Lazy = Lazy::new(|| { + let mut b = Builder::new(); + b.tags(HashSet::new()); + b + }); + builder.clean(html).to_string() +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ef9c70f..61d2c6b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod urls; pub mod markdown; +pub mod html; pub mod jinja_extra; pub fn split_search_query<'a>(query: Option<&'a str>) -> Option> {