This commit is contained in:
slonkazoid 2024-05-02 19:23:20 +03:00
parent 086ddb7665
commit 457692f766
Signed by: slonk
SSH key fingerprint: SHA256:tbZfJX4IOvZ0LGWOWu5Ijo8jfMPi78TU7x1VoEeCIjM
7 changed files with 263 additions and 10 deletions

115
Cargo.lock generated
View file

@ -128,6 +128,19 @@ dependencies = [
"syn 2.0.60", "syn 2.0.60",
] ]
[[package]]
name = "atom_syndication"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "571832dcff775e26562e8e6930cd483de5587301d40d3a3b85d532b6383e15a7"
dependencies = [
"chrono",
"derive_builder",
"diligent-date-parser",
"never",
"quick-xml 0.30.0",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.2.0" version = "1.2.0"
@ -290,6 +303,7 @@ dependencies = [
"comrak", "comrak",
"console-subscriber", "console-subscriber",
"fronma", "fronma",
"rss",
"scc", "scc",
"serde", "serde",
"syntect", "syntect",
@ -300,6 +314,7 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"zstd", "zstd",
] ]
@ -576,12 +591,30 @@ version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322ef0094744e63628e6f0eb2295517f79276a5b342a4c2ff3042566ca181d4e" checksum = "322ef0094744e63628e6f0eb2295517f79276a5b342a4c2ff3042566ca181d4e"
[[package]]
name = "diligent-date-parser"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6cf7fe294274a222363f84bcb63cdea762979a0443b4cf1f4f8fd17c86b1182"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.11.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2"
[[package]]
name = "encoding_rs"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "entities" name = "entities"
version = "1.0.1" version = "1.0.1"
@ -918,6 +951,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]] [[package]]
name = "indenter" name = "indenter"
version = "0.3.3" version = "0.3.3"
@ -1061,6 +1104,12 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "never"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -1208,7 +1257,7 @@ dependencies = [
"base64", "base64",
"indexmap 2.2.6", "indexmap 2.2.6",
"line-wrap", "line-wrap",
"quick-xml", "quick-xml 0.31.0",
"serde", "serde",
"time", "time",
] ]
@ -1266,6 +1315,16 @@ dependencies = [
"prost", "prost",
] ]
[[package]]
name = "quick-xml"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.31.0" version = "0.31.0"
@ -1358,6 +1417,18 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
[[package]]
name = "rss"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7b2c77eb4450d7d5f98df52c381cd6c4e19b75dad9209a9530b85a44510219a"
dependencies = [
"atom_syndication",
"derive_builder",
"never",
"quick-xml 0.30.0",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.23" version = "0.1.23"
@ -1652,6 +1723,21 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinyvec"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.37.0" version = "1.37.0"
@ -1929,18 +2015,45 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "unicode-bidi"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.12" version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-normalization"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
dependencies = [
"tinyvec",
]
[[package]] [[package]]
name = "unicode_categories" name = "unicode_categories"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "url"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.0" version = "0.1.0"

View file

@ -37,6 +37,7 @@ comrak = { version = "0.22.0", features = [
], default-features = false } ], default-features = false }
console-subscriber = { version = "0.2.0", optional = true } console-subscriber = { version = "0.2.0", optional = true }
fronma = "0.2.0" fronma = "0.2.0"
rss = "2.0.7"
scc = { version = "2.1.0", features = ["serde"] } scc = { version = "2.1.0", features = ["serde"] }
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
syntect = "5.2.0" syntect = "5.2.0"
@ -56,4 +57,5 @@ tower-http = { version = "0.5.2", features = [
], default-features = false } ], default-features = false }
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
url = { version = "2.5.0", features = ["serde"] }
zstd = { version = "0.13.1", default-features = false } zstd = { version = "0.13.1", default-features = false }

View file

@ -11,7 +11,7 @@ blazingly fast markdown blog software written in rust memory safe
## TODO ## TODO
- [ ] RSS - [x] RSS
- [x] finish writing this document - [x] finish writing this document
- [x] document config - [x] document config
- [ ] extend syntect options - [ ] extend syntect options
@ -27,6 +27,7 @@ blazingly fast markdown blog software written in rust memory safe
- [x] clean up imports and require less features - [x] clean up imports and require less features
- [ ] improve home page - [ ] improve home page
- [x] tags - [x] tags
- [ ] multi-language support
- [x] be blazingly fast - [x] be blazingly fast
- [x] 100+ MiB binary size - [x] 100+ MiB binary size
@ -39,6 +40,11 @@ title = "bingus-blog" # title of the website
description = "blazingly fast markdown blog software written in rust memory safe" # description of the website description = "blazingly fast markdown blog software written in rust memory safe" # description of the website
raw_access = true # allow users to see the raw markdown of a post raw_access = true # allow users to see the raw markdown of a post
[rss]
enable = false # serve an rss field under /feed.xml
# this may be a bit resource intensive
link = "https://..." # public url of the blog, required if rss is enabled
[dirs] [dirs]
posts = "posts" # where posts are stored posts = "posts" # where posts are stored
media = "media" # directory served under /media/ media = "media" # directory served under /media/

View file

@ -6,6 +6,7 @@ use color_eyre::eyre::{bail, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{error, info}; use tracing::{error, info};
use url::Url;
use crate::ranged_i128_visitor::RangedI128Visitor; use crate::ranged_i128_visitor::RangedI128Visitor;
@ -50,6 +51,12 @@ pub struct DirsConfig {
pub media: PathBuf, pub media: PathBuf,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RssConfig {
pub enable: bool,
pub link: Url,
}
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
@ -57,6 +64,7 @@ pub struct Config {
pub description: String, pub description: String,
pub raw_access: bool, pub raw_access: bool,
pub num_posts: usize, pub num_posts: usize,
pub rss: RssConfig,
pub dirs: DirsConfig, pub dirs: DirsConfig,
pub http: HttpConfig, pub http: HttpConfig,
pub render: RenderConfig, pub render: RenderConfig,
@ -70,6 +78,16 @@ impl Default for Config {
description: "blazingly fast markdown blog software written in rust memory safe".into(), description: "blazingly fast markdown blog software written in rust memory safe".into(),
raw_access: true, raw_access: true,
num_posts: 5, num_posts: 5,
// i have a love-hate relationship with serde
// it was engimatic at first, but then i started actually using it
// writing my own serialize and deserialize implementations.. spending
// a lot of time in the docs trying to understand each and every option..
// now with this knowledge i can do stuff like this! (see rss field)
// and i'm proud to say that it still makes 0 sense.
rss: RssConfig {
enable: false,
link: Url::parse("http://example.com").unwrap(),
},
dirs: Default::default(), dirs: Default::default(),
http: Default::default(), http: Default::default(),
render: Default::default(), render: Default::default(),

View file

@ -53,6 +53,10 @@ pub type AppResult<T> = Result<T, AppError>;
pub enum AppError { pub enum AppError {
#[error("failed to fetch post: {0}")] #[error("failed to fetch post: {0}")]
PostError(#[from] PostError), PostError(#[from] PostError),
#[error("rss is disabled")]
RssDisabled,
#[error(transparent)]
UrlError(#[from] url::ParseError),
} }
impl From<std::io::Error> for AppError { impl From<std::io::Error> for AppError {
@ -75,7 +79,8 @@ impl IntoResponse for AppError {
PostError::NotFound(_) => StatusCode::NOT_FOUND, PostError::NotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}, },
//_ => StatusCode::INTERNAL_SERVER_ERROR, AppError::RssDisabled => StatusCode::FORBIDDEN,
AppError::UrlError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}; };
( (
status_code, status_code,

View file

@ -18,11 +18,13 @@ use std::time::Duration;
use askama_axum::Template; use askama_axum::Template;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::Request; use axum::http::{header, Request};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, Router}; use axum::routing::{get, Router};
use axum::Json; use axum::Json;
use color_eyre::eyre::{self, Context}; use color_eyre::eyre::{self, Context};
use error::AppError;
use rss::{Category, ChannelBuilder, ItemBuilder};
use serde::Deserialize; use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
@ -78,7 +80,7 @@ async fn index(
) -> AppResult<IndexTemplate> { ) -> AppResult<IndexTemplate> {
let posts = state let posts = state
.posts .posts
.get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?; .await?;
Ok(IndexTemplate { Ok(IndexTemplate {
@ -94,12 +96,68 @@ async fn all_posts(
) -> AppResult<Json<Vec<PostMetadata>>> { ) -> AppResult<Json<Vec<PostMetadata>>> {
let posts = state let posts = state
.posts .posts
.get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?; .await?;
Ok(Json(posts)) Ok(Json(posts))
} }
async fn rss(
State(state): State<ArcState>,
Query(query): Query<QueryParams>,
) -> AppResult<Response> {
if !state.config.rss.enable {
return Err(AppError::RssDisabled);
}
let posts = state
.posts
.get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?;
let mut channel = ChannelBuilder::default();
channel
.title(&state.config.title)
.link(state.config.rss.link.to_string())
.description(&state.config.description);
//TODO: .language()
for (metadata, content, _) in posts {
channel.item(
ItemBuilder::default()
.title(metadata.title)
.description(metadata.description)
.author(metadata.author)
.categories(
metadata
.tags
.into_iter()
.map(|tag| Category {
name: tag,
domain: None,
})
.collect::<Vec<Category>>(),
)
.pub_date(metadata.created_at.map(|date| date.to_rfc2822()))
.content(content)
.link(
state
.config
.rss
.link
.join(&format!("/posts/{}", metadata.name))?
.to_string(),
)
.build(),
);
}
let body = channel.build().to_string();
drop(channel);
Ok(([(header::CONTENT_TYPE, "text/xml")], body).into_response())
}
async fn post(State(state): State<ArcState>, Path(name): Path<String>) -> AppResult<Response> { async fn post(State(state): State<ArcState>, Path(name): Path<String>) -> AppResult<Response> {
if name.ends_with(".md") && state.config.raw_access { if name.ends_with(".md") && state.config.raw_access {
let mut file = tokio::fs::OpenOptions::new() let mut file = tokio::fs::OpenOptions::new()
@ -250,6 +308,7 @@ async fn main() -> eyre::Result<()> {
) )
.route("/posts/:name", get(post)) .route("/posts/:name", get(post))
.route("/posts", get(all_posts)) .route("/posts", get(all_posts))
.route("/feed.xml", get(rss))
.nest_service("/static", ServeDir::new("static").precompressed_gzip()) .nest_service("/static", ServeDir::new("static").precompressed_gzip())
.nest_service("/media", ServeDir::new("media")) .nest_service("/media", ServeDir::new("media"))
.layer( .layer(

View file

@ -200,7 +200,36 @@ impl PostManager {
Ok(posts) Ok(posts)
} }
pub async fn get_max_n_posts_with_optional_tag_sorted( pub async fn get_all_posts_filtered(
&self,
filter: impl Fn(&PostMetadata, &str) -> bool,
) -> Result<Vec<(PostMetadata, String, RenderStats)>, PostError> {
let mut posts = Vec::new();
let mut read_dir = fs::read_dir(&self.dir).await?;
while let Some(entry) = read_dir.next_entry().await? {
let path = entry.path();
let stat = fs::metadata(&path).await?;
if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") {
let name = path
.clone()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let post = self.get_post(&name).await?;
if filter(&post.0, &post.1) {
posts.push(post);
}
}
}
Ok(posts)
}
pub async fn get_max_n_post_metadata_with_optional_tag_sorted(
&self, &self,
n: Option<usize>, n: Option<usize>,
tag: Option<&String>, tag: Option<&String>,
@ -210,13 +239,34 @@ impl PostManager {
!tag.is_some_and(|tag| !metadata.tags.contains(tag)) !tag.is_some_and(|tag| !metadata.tags.contains(tag))
}) })
.await?; .await?;
posts.sort_unstable_by_key(|metadata| metadata.created_at.unwrap_or_default()); // we still want some semblance of order if created_at is None so sort by mtime as well
posts.sort_unstable_by_key(|metadata| metadata.modified_at.unwrap_or_default());
posts.sort_by_key(|metadata| metadata.created_at.unwrap_or_default());
posts.reverse();
if let Some(n) = n { if let Some(n) = n {
posts = Vec::from(&posts[posts.len().saturating_sub(n)..]); posts.truncate(n);
} }
Ok(posts)
}
pub async fn get_max_n_posts_with_optional_tag_sorted(
&self,
n: Option<usize>,
tag: Option<&String>,
) -> Result<Vec<(PostMetadata, String, RenderStats)>, PostError> {
let mut posts = self
.get_all_posts_filtered(|metadata, _| {
!tag.is_some_and(|tag| !metadata.tags.contains(tag))
})
.await?;
posts.sort_unstable_by_key(|(metadata, ..)| metadata.modified_at.unwrap_or_default());
posts.sort_by_key(|(metadata, ..)| metadata.created_at.unwrap_or_default());
posts.reverse(); posts.reverse();
if let Some(n) = n {
posts.truncate(n);
}
Ok(posts) Ok(posts)
} }