Compare commits
No commits in common. "9dfe0ebddf758735dab603e5cc4b36482d1cdbea" and "bd7823dc148d294935e9e13221f16009260ed44b" have entirely different histories.
9dfe0ebddf
...
bd7823dc14
15 changed files with 578 additions and 779 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
@ -302,7 +302,6 @@ dependencies = [
|
|||
"color-eyre",
|
||||
"comrak",
|
||||
"console-subscriber",
|
||||
"derive_more",
|
||||
"fronma",
|
||||
"rss",
|
||||
"scc",
|
||||
|
@ -487,12 +486,6 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.0"
|
||||
|
@ -592,19 +585,6 @@ dependencies = [
|
|||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.4.4"
|
||||
|
@ -1455,15 +1435,6 @@ version = "0.1.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.15"
|
||||
|
@ -1501,12 +1472,6 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.198"
|
||||
|
|
|
@ -36,7 +36,6 @@ comrak = { version = "0.22.0", features = [
|
|||
"syntect",
|
||||
], default-features = false }
|
||||
console-subscriber = { version = "0.2.0", optional = true }
|
||||
derive_more = "0.99.17"
|
||||
fronma = "0.2.0"
|
||||
rss = "2.0.7"
|
||||
scc = { version = "2.1.0", features = ["serde"] }
|
||||
|
|
15
README.md
15
README.md
|
@ -22,7 +22,7 @@ blazingly fast markdown blog software written in rust memory safe
|
|||
- [ ] ^ replace HashMap with HashCache once i implement [this](https://github.com/wvwwvwwv/scalable-concurrent-containers/issues/139)
|
||||
- [x] (de)compress cache with zstd on startup/shutdown
|
||||
- [ ] make date parsing less strict
|
||||
- [x] make date formatting better
|
||||
- [ ] make date formatting better
|
||||
- [ ] date formatting respects user timezone
|
||||
- [x] clean up imports and require less features
|
||||
- [ ] improve home page
|
||||
|
@ -36,16 +36,9 @@ blazingly fast markdown blog software written in rust memory safe
|
|||
the default configuration with comments looks like this
|
||||
|
||||
```toml
|
||||
title = "bingus-blog" # title of the blog
|
||||
# description of the blog
|
||||
description = "blazingly fast markdown blog software written in rust memory safe"
|
||||
markdown_access = true # allow users to see the raw markdown of a post
|
||||
# endpoint: /posts/<name>.md
|
||||
date_format = "RFC3339" # format string used to format dates in the backend
|
||||
# it's highly recommended to leave this as default,
|
||||
# so the date can be formatted by the browser.
|
||||
# format: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
|
||||
js_enable = true # enable javascript (required for above)
|
||||
title = "bingus-blog" # title 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
|
||||
|
||||
[rss]
|
||||
enable = false # serve an rss field under /feed.xml
|
||||
|
|
200
src/app.rs
200
src/app.rs
|
@ -1,200 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use askama_axum::Template;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::{header, Request};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use rss::{Category, ChannelBuilder, ItemBuilder};
|
||||
use serde::Deserialize;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, info_span, Span};
|
||||
|
||||
use crate::config::{Config, DateFormat};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::filters;
|
||||
use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub posts: Arc<MarkdownPosts<Arc<Config>>>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
title: String,
|
||||
description: String,
|
||||
posts: Vec<PostMetadata>,
|
||||
rss: bool,
|
||||
df: DateFormat,
|
||||
js: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "post.html")]
|
||||
struct PostTemplate {
|
||||
meta: PostMetadata,
|
||||
rendered: String,
|
||||
rendered_in: RenderStats,
|
||||
markdown_access: bool,
|
||||
df: DateFormat,
|
||||
js: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryParams {
|
||||
tag: Option<String>,
|
||||
#[serde(rename = "n")]
|
||||
num_posts: Option<usize>,
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(AppState { config, posts }): State<AppState>,
|
||||
Query(query): Query<QueryParams>,
|
||||
) -> AppResult<IndexTemplate> {
|
||||
let posts = posts
|
||||
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(IndexTemplate {
|
||||
title: config.title.clone(),
|
||||
description: config.description.clone(),
|
||||
posts,
|
||||
rss: config.rss.enable,
|
||||
df: config.date_format.clone(),
|
||||
js: config.js_enable,
|
||||
})
|
||||
}
|
||||
|
||||
async fn all_posts(
|
||||
State(AppState { posts, .. }): State<AppState>,
|
||||
Query(query): Query<QueryParams>,
|
||||
) -> AppResult<Json<Vec<PostMetadata>>> {
|
||||
let posts = posts
|
||||
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(Json(posts))
|
||||
}
|
||||
|
||||
async fn rss(
|
||||
State(AppState { config, posts }): State<AppState>,
|
||||
Query(query): Query<QueryParams>,
|
||||
) -> AppResult<Response> {
|
||||
if !config.rss.enable {
|
||||
return Err(AppError::RssDisabled);
|
||||
}
|
||||
|
||||
let posts = posts
|
||||
.get_all_posts(|metadata, _| {
|
||||
!query
|
||||
.tag
|
||||
.as_ref()
|
||||
.is_some_and(|tag| !metadata.tags.contains(tag))
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut channel = ChannelBuilder::default();
|
||||
channel
|
||||
.title(&config.title)
|
||||
.link(config.rss.link.to_string())
|
||||
.description(&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(
|
||||
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(AppState { config, posts }): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
match posts.get_post(&name).await? {
|
||||
ReturnedPost::Rendered(meta, rendered, rendered_in) => {
|
||||
let page = PostTemplate {
|
||||
meta,
|
||||
rendered,
|
||||
rendered_in,
|
||||
markdown_access: config.markdown_access,
|
||||
df: config.date_format.clone(),
|
||||
js: config.js_enable,
|
||||
};
|
||||
|
||||
Ok(page.into_response())
|
||||
}
|
||||
ReturnedPost::Raw(body, content_type) => {
|
||||
Ok(([(CONTENT_TYPE, content_type)], body).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(config: &Config) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route(
|
||||
"/post/:name",
|
||||
get(
|
||||
|Path(name): Path<String>| async move { Redirect::to(&format!("/posts/{}", name)) },
|
||||
),
|
||||
)
|
||||
.route("/posts/:name", get(post))
|
||||
.route("/posts", get(all_posts))
|
||||
.route("/feed.xml", get(rss))
|
||||
.nest_service(
|
||||
"/static",
|
||||
ServeDir::new(&config.dirs._static).precompressed_gzip(),
|
||||
)
|
||||
.nest_service("/media", ServeDir::new(&config.dirs.media))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<_>| {
|
||||
info_span!(
|
||||
"request",
|
||||
method = ?request.method(),
|
||||
path = ?request.uri().path(),
|
||||
)
|
||||
})
|
||||
.on_response(|response: &Response<_>, duration: Duration, span: &Span| {
|
||||
let _ = span.enter();
|
||||
let status = response.status();
|
||||
info!(?status, ?duration, "response");
|
||||
}),
|
||||
)
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
use std::env;
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use color_eyre::eyre::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tracing::{error, info, instrument};
|
||||
use tracing::{error, info};
|
||||
use url::Url;
|
||||
|
||||
use crate::ranged_i128_visitor::RangedI128Visitor;
|
||||
|
@ -49,8 +49,6 @@ pub struct HttpConfig {
|
|||
pub struct DirsConfig {
|
||||
pub posts: PathBuf,
|
||||
pub media: PathBuf,
|
||||
#[serde(rename = "static")]
|
||||
pub _static: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
|
@ -59,22 +57,13 @@ pub struct RssConfig {
|
|||
pub link: Url,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub enum DateFormat {
|
||||
#[default]
|
||||
RFC3339,
|
||||
#[serde(untagged)]
|
||||
Strftime(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub markdown_access: bool,
|
||||
pub date_format: DateFormat,
|
||||
pub js_enable: bool,
|
||||
pub raw_access: bool,
|
||||
pub num_posts: usize,
|
||||
pub rss: RssConfig,
|
||||
pub dirs: DirsConfig,
|
||||
pub http: HttpConfig,
|
||||
|
@ -87,9 +76,8 @@ impl Default for Config {
|
|||
Self {
|
||||
title: "bingus-blog".into(),
|
||||
description: "blazingly fast markdown blog software written in rust memory safe".into(),
|
||||
markdown_access: true,
|
||||
date_format: Default::default(),
|
||||
js_enable: true,
|
||||
raw_access: true,
|
||||
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
|
||||
|
@ -113,7 +101,6 @@ impl Default for DirsConfig {
|
|||
Self {
|
||||
posts: "posts".into(),
|
||||
media: "media".into(),
|
||||
_static: "static".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,7 +108,7 @@ impl Default for DirsConfig {
|
|||
impl Default for HttpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
host: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
port: 3000,
|
||||
}
|
||||
}
|
||||
|
@ -151,7 +138,6 @@ impl Default for CacheConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[instrument(name = "config")]
|
||||
pub async fn load() -> Result<Config> {
|
||||
let config_file = env::var(format!(
|
||||
"{}_CONFIG",
|
||||
|
|
|
@ -1,29 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use chrono::{DateTime, TimeZone};
|
||||
|
||||
use crate::config::DateFormat;
|
||||
use crate::post::PostMetadata;
|
||||
|
||||
fn format_date<T>(date: &DateTime<T>, date_format: &DateFormat) -> String
|
||||
where
|
||||
T: TimeZone,
|
||||
T::Offset: Display,
|
||||
{
|
||||
match date_format {
|
||||
DateFormat::RFC3339 => date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
DateFormat::Strftime(ref format_string) => date.format(format_string).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn date<T>(date: &DateTime<T>, date_format: &DateFormat) -> Result<String, askama::Error>
|
||||
where
|
||||
T: TimeZone,
|
||||
T::Offset: Display,
|
||||
{
|
||||
Ok(format_date(date, date_format))
|
||||
pub fn date<T: TimeZone>(date: &DateTime<T>) -> Result<String, askama::Error> {
|
||||
Ok(date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||
}
|
||||
|
||||
pub fn duration(duration: &&Duration) -> Result<String, askama::Error> {
|
||||
|
|
324
src/main.rs
324
src/main.rs
|
@ -1,6 +1,5 @@
|
|||
#![feature(let_chains)]
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod error;
|
||||
mod filters;
|
||||
|
@ -11,23 +10,184 @@ mod ranged_i128_visitor;
|
|||
mod systemtime_as_secs;
|
||||
|
||||
use std::future::IntoFuture;
|
||||
use std::io::Read;
|
||||
use std::net::SocketAddr;
|
||||
use std::process::exit;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use askama_axum::Template;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, Request};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, Router};
|
||||
use axum::Json;
|
||||
use color_eyre::eyre::{self, Context};
|
||||
use error::AppError;
|
||||
use rss::{Category, ChannelBuilder, ItemBuilder};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::{select, signal};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::{util::SubscriberInitExt, EnvFilter};
|
||||
use tracing::{debug, error, info, info_span, warn, Span};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::post::{MarkdownPosts, PostManager};
|
||||
use crate::config::Config;
|
||||
use crate::error::{AppResult, PostError};
|
||||
use crate::post::cache::{Cache, CACHE_VERSION};
|
||||
use crate::post::{PostManager, PostMetadata, RenderStats};
|
||||
|
||||
type ArcState = Arc<AppState>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pub config: Config,
|
||||
pub posts: PostManager,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
title: String,
|
||||
description: String,
|
||||
posts: Vec<PostMetadata>,
|
||||
rss: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "post.html")]
|
||||
struct PostTemplate {
|
||||
meta: PostMetadata,
|
||||
rendered: String,
|
||||
rendered_in: RenderStats,
|
||||
markdown_access: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryParams {
|
||||
tag: Option<String>,
|
||||
#[serde(rename = "n")]
|
||||
num_posts: Option<usize>,
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(state): State<ArcState>,
|
||||
Query(query): Query<QueryParams>,
|
||||
) -> AppResult<IndexTemplate> {
|
||||
let posts = state
|
||||
.posts
|
||||
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(IndexTemplate {
|
||||
title: state.config.title.clone(),
|
||||
description: state.config.description.clone(),
|
||||
posts,
|
||||
rss: state.config.rss.enable,
|
||||
})
|
||||
}
|
||||
|
||||
async fn all_posts(
|
||||
State(state): State<ArcState>,
|
||||
Query(query): Query<QueryParams>,
|
||||
) -> AppResult<Json<Vec<PostMetadata>>> {
|
||||
let posts = state
|
||||
.posts
|
||||
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
|
||||
.await?;
|
||||
|
||||
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_all_posts_filtered(|metadata, _| {
|
||||
!query
|
||||
.tag
|
||||
.as_ref()
|
||||
.is_some_and(|tag| !metadata.tags.contains(tag))
|
||||
})
|
||||
.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> {
|
||||
if name.ends_with(".md") && state.config.raw_access {
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.open(state.config.dirs.posts.join(&name))
|
||||
.await?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
|
||||
Ok(([("content-type", "text/plain")], buf).into_response())
|
||||
} else {
|
||||
let post = state.posts.get_post(&name).await?;
|
||||
let page = PostTemplate {
|
||||
meta: post.0,
|
||||
rendered: post.1,
|
||||
rendered_in: post.2,
|
||||
markdown_access: state.config.raw_access,
|
||||
};
|
||||
|
||||
Ok(page.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
|
@ -44,26 +204,89 @@ async fn main() -> eyre::Result<()> {
|
|||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Arc::new(
|
||||
config::load()
|
||||
.await
|
||||
.context("couldn't load configuration")?,
|
||||
);
|
||||
let config = config::load()
|
||||
.await
|
||||
.context("couldn't load configuration")?;
|
||||
|
||||
let socket_addr = SocketAddr::new(config.http.host, config.http.port);
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
let cancellation_token = CancellationToken::new();
|
||||
|
||||
let posts = Arc::new(MarkdownPosts::new(Arc::clone(&config)).await?);
|
||||
let state = AppState {
|
||||
config: Arc::clone(&config),
|
||||
posts: Arc::clone(&posts),
|
||||
let posts = if config.cache.enable {
|
||||
if config.cache.persistence
|
||||
&& tokio::fs::try_exists(&config.cache.file)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("failed to check if {} exists", config.cache.file.display())
|
||||
})?
|
||||
{
|
||||
info!("loading cache from file");
|
||||
let path = &config.cache.file;
|
||||
let load_cache = async {
|
||||
let mut cache_file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.context("failed to open cache file")?;
|
||||
let serialized = if config.cache.compress {
|
||||
let cache_file = cache_file.into_std().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?;
|
||||
Ok::<_, std::io::Error>(buf)
|
||||
})
|
||||
.await
|
||||
.context("failed to join blocking thread")?
|
||||
.context("failed to read cache file")?
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
cache_file
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.context("failed to read cache file")?;
|
||||
buf
|
||||
};
|
||||
let mut cache: Cache =
|
||||
bitcode::deserialize(serialized.as_slice()).context("failed to parse cache")?;
|
||||
if cache.version() < CACHE_VERSION {
|
||||
warn!("cache version changed, clearing cache");
|
||||
cache = Cache::default();
|
||||
};
|
||||
|
||||
Ok::<PostManager, color_eyre::Report>(PostManager::new_with_cache(
|
||||
config.dirs.posts.clone(),
|
||||
config.render.clone(),
|
||||
cache,
|
||||
))
|
||||
}
|
||||
.await;
|
||||
match load_cache {
|
||||
Ok(posts) => posts,
|
||||
Err(err) => {
|
||||
error!("failed to load cache: {}", err);
|
||||
info!("using empty cache");
|
||||
PostManager::new_with_cache(
|
||||
config.dirs.posts.clone(),
|
||||
config.render.clone(),
|
||||
Default::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PostManager::new_with_cache(
|
||||
config.dirs.posts.clone(),
|
||||
config.render.clone(),
|
||||
Default::default(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PostManager::new(config.dirs.posts.clone(), config.render.clone())
|
||||
};
|
||||
|
||||
if config.cache.enable && config.cache.cleanup {
|
||||
if let Some(t) = config.cache.cleanup_interval {
|
||||
let posts = Arc::clone(&posts);
|
||||
let state = Arc::new(AppState { config, posts });
|
||||
|
||||
if state.config.cache.enable && state.config.cache.cleanup {
|
||||
if let Some(t) = state.config.cache.cleanup_interval {
|
||||
let state = Arc::clone(&state);
|
||||
let token = cancellation_token.child_token();
|
||||
debug!("setting up cleanup task");
|
||||
tasks.spawn(async move {
|
||||
|
@ -72,17 +295,45 @@ async fn main() -> eyre::Result<()> {
|
|||
select! {
|
||||
_ = token.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
posts.cleanup().await
|
||||
state.posts.cleanup().await
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
posts.cleanup().await;
|
||||
state.posts.cleanup().await;
|
||||
}
|
||||
}
|
||||
|
||||
let app = app::new(&config).with_state(state.clone());
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route(
|
||||
"/post/:name",
|
||||
get(
|
||||
|Path(name): Path<String>| async move { Redirect::to(&format!("/posts/{}", name)) },
|
||||
),
|
||||
)
|
||||
.route("/posts/:name", get(post))
|
||||
.route("/posts", get(all_posts))
|
||||
.route("/feed.xml", get(rss))
|
||||
.nest_service("/static", ServeDir::new("static").precompressed_gzip())
|
||||
.nest_service("/media", ServeDir::new("media"))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<_>| {
|
||||
info_span!(
|
||||
"request",
|
||||
method = ?request.method(),
|
||||
path = ?request.uri().path(),
|
||||
)
|
||||
})
|
||||
.on_response(|response: &Response<_>, duration: Duration, span: &Span| {
|
||||
let _ = span.enter();
|
||||
let status = response.status();
|
||||
info!(?status, ?duration, "response");
|
||||
}),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = TcpListener::bind(socket_addr)
|
||||
.await
|
||||
|
@ -130,7 +381,36 @@ async fn main() -> eyre::Result<()> {
|
|||
task.context("failed to join task")?;
|
||||
}
|
||||
|
||||
drop(state);
|
||||
// write cache to file
|
||||
let config = &state.config;
|
||||
let posts = &state.posts;
|
||||
if config.cache.enable
|
||||
&& config.cache.persistence
|
||||
&& let Some(cache) = posts.cache()
|
||||
{
|
||||
let path = &config.cache.file;
|
||||
let serialized = bitcode::serialize(cache).context("failed to serialize cache")?;
|
||||
let mut cache_file = tokio::fs::File::create(path)
|
||||
.await
|
||||
.with_context(|| format!("failed to open cache at {}", path.display()))?;
|
||||
let compression_level = config.cache.compression_level;
|
||||
if config.cache.compress {
|
||||
let cache_file = cache_file.into_std().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::io::Write::write_all(
|
||||
&mut zstd::stream::write::Encoder::new(cache_file, compression_level)?
|
||||
.auto_finish(),
|
||||
&serialized,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.context("failed to join blocking thread")?
|
||||
} else {
|
||||
cache_file.write_all(&serialized).await
|
||||
}
|
||||
.context("failed to write cache to file")?;
|
||||
info!("wrote cache to {}", path.display());
|
||||
}
|
||||
Ok::<(), color_eyre::Report>(())
|
||||
};
|
||||
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::config::{Config, RenderConfig};
|
||||
use crate::post::PostMetadata;
|
||||
use color_eyre::eyre::{self, Context};
|
||||
use scc::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::config::RenderConfig;
|
||||
use crate::post::PostMetadata;
|
||||
|
||||
/// do not persist cache if this version number changed
|
||||
pub const CACHE_VERSION: u16 = 2;
|
||||
|
||||
|
@ -135,29 +133,3 @@ impl Cache {
|
|||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_cache(config: &Config) -> Result<Cache, eyre::Report> {
|
||||
let path = &config.cache.file;
|
||||
let mut cache_file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.context("failed to open cache file")?;
|
||||
let serialized = if config.cache.compress {
|
||||
let cache_file = cache_file.into_std().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
zstd::stream::read::Decoder::new(cache_file)?.read_to_end(&mut buf)?;
|
||||
Ok::<_, std::io::Error>(buf)
|
||||
})
|
||||
.await?
|
||||
.context("failed to read cache file")?
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
cache_file
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.context("failed to read cache file")?;
|
||||
buf
|
||||
};
|
||||
|
||||
bitcode::deserialize(serialized.as_slice()).context("failed to parse cache")
|
||||
}
|
||||
|
|
|
@ -1,344 +0,0 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::io::{self, Write};
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::http::HeaderValue;
|
||||
use chrono::{DateTime, Utc};
|
||||
use color_eyre::eyre::{self, Context};
|
||||
use fronma::parser::{parse, ParsedData};
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::markdown_render::render;
|
||||
use crate::post::cache::{load_cache, Cache, CACHE_VERSION};
|
||||
use crate::post::{PostError, PostManager, PostMetadata, RenderStats, ReturnedPost};
|
||||
use crate::systemtime_as_secs::as_secs;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FrontMatter {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub author: String,
|
||||
pub icon: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub modified_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl FrontMatter {
|
||||
pub fn into_full(
|
||||
self,
|
||||
name: String,
|
||||
created: Option<SystemTime>,
|
||||
modified: Option<SystemTime>,
|
||||
) -> PostMetadata {
|
||||
PostMetadata {
|
||||
name,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
author: self.author,
|
||||
icon: self.icon,
|
||||
created_at: self.created_at.or_else(|| created.map(|t| t.into())),
|
||||
modified_at: self.modified_at.or_else(|| modified.map(|t| t.into())),
|
||||
tags: self.tags.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct MarkdownPosts<C>
|
||||
where
|
||||
C: Deref<Target = Config>,
|
||||
{
|
||||
cache: Option<Cache>,
|
||||
config: C,
|
||||
}
|
||||
|
||||
impl<C> MarkdownPosts<C>
|
||||
where
|
||||
C: Deref<Target = Config>,
|
||||
{
|
||||
pub async fn new(config: C) -> eyre::Result<MarkdownPosts<C>> {
|
||||
if config.cache.enable {
|
||||
if config.cache.persistence && tokio::fs::try_exists(&config.cache.file).await? {
|
||||
info!("loading cache from file");
|
||||
let mut cache = load_cache(&config).await.unwrap_or_else(|err| {
|
||||
error!("failed to load cache: {}", err);
|
||||
info!("using empty cache");
|
||||
Default::default()
|
||||
});
|
||||
|
||||
if cache.version() < CACHE_VERSION {
|
||||
warn!("cache version changed, clearing cache");
|
||||
cache = Default::default();
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
cache: Some(cache),
|
||||
config,
|
||||
})
|
||||
} else {
|
||||
Ok(Self {
|
||||
cache: Some(Default::default()),
|
||||
config,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Ok(Self {
|
||||
cache: None,
|
||||
config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_and_render(
|
||||
&self,
|
||||
name: String,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<(PostMetadata, String, (Duration, Duration)), PostError> {
|
||||
let parsing_start = Instant::now();
|
||||
let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await {
|
||||
Ok(val) => val,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => return Err(PostError::NotFound(name)),
|
||||
_ => return Err(PostError::IoError(err)),
|
||||
},
|
||||
};
|
||||
let stat = file.metadata().await?;
|
||||
let modified = stat.modified()?;
|
||||
let created = stat.created().ok();
|
||||
|
||||
let mut content = String::with_capacity(stat.len() as usize);
|
||||
file.read_to_string(&mut content).await?;
|
||||
|
||||
let ParsedData { headers, body } = parse::<FrontMatter>(&content)?;
|
||||
let metadata = headers.into_full(name.to_owned(), created, Some(modified));
|
||||
let parsing = parsing_start.elapsed();
|
||||
|
||||
let before_render = Instant::now();
|
||||
let post = render(body, &self.config.render);
|
||||
let rendering = before_render.elapsed();
|
||||
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache
|
||||
.insert(
|
||||
name.to_string(),
|
||||
metadata.clone(),
|
||||
as_secs(&modified),
|
||||
post.clone(),
|
||||
&self.config.render,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0))
|
||||
};
|
||||
|
||||
Ok((metadata, post, (parsing, rendering)))
|
||||
}
|
||||
|
||||
fn cache(&self) -> Option<&Cache> {
|
||||
self.cache.as_ref()
|
||||
}
|
||||
|
||||
fn try_drop(&mut self) -> Result<(), eyre::Report> {
|
||||
// write cache to file
|
||||
let config = &self.config.cache;
|
||||
if config.enable
|
||||
&& config.persistence
|
||||
&& let Some(cache) = self.cache()
|
||||
{
|
||||
let path = &config.file;
|
||||
let serialized = bitcode::serialize(cache).context("failed to serialize cache")?;
|
||||
let mut cache_file = std::fs::File::create(path)
|
||||
.with_context(|| format!("failed to open cache at {}", path.display()))?;
|
||||
let compression_level = config.compression_level;
|
||||
if config.compress {
|
||||
std::io::Write::write_all(
|
||||
&mut zstd::stream::write::Encoder::new(cache_file, compression_level)?
|
||||
.auto_finish(),
|
||||
&serialized,
|
||||
)
|
||||
} else {
|
||||
cache_file.write_all(&serialized)
|
||||
}
|
||||
.context("failed to write cache to file")?;
|
||||
info!("wrote cache to {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> Drop for MarkdownPosts<C>
|
||||
where
|
||||
C: Deref<Target = Config>,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
self.try_drop().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> PostManager for MarkdownPosts<C>
|
||||
where
|
||||
C: Deref<Target = Config>,
|
||||
{
|
||||
async fn get_all_post_metadata(
|
||||
&self,
|
||||
filter: impl Fn(&PostMetadata) -> bool,
|
||||
) -> Result<Vec<PostMetadata>, PostError> {
|
||||
let mut posts = Vec::new();
|
||||
|
||||
let mut read_dir = fs::read_dir(&self.config.dirs.posts).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 mtime = as_secs(&stat.modified()?);
|
||||
// TODO. this?
|
||||
let name = path
|
||||
.clone()
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Some(hit) = cache.lookup_metadata(&name, mtime).await
|
||||
&& filter(&hit)
|
||||
{
|
||||
posts.push(hit);
|
||||
} else {
|
||||
match self.parse_and_render(name, path).await {
|
||||
Ok((metadata, ..)) => {
|
||||
if filter(&metadata) {
|
||||
posts.push(metadata);
|
||||
}
|
||||
}
|
||||
Err(err) => match err {
|
||||
PostError::IoError(ref io_err)
|
||||
if matches!(io_err.kind(), io::ErrorKind::NotFound) =>
|
||||
{
|
||||
warn!("TOCTOU: {}", err)
|
||||
}
|
||||
_ => return Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
async fn get_all_posts(
|
||||
&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.config.dirs.posts).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 let ReturnedPost::Rendered(meta, content, stats) = post
|
||||
&& filter(&meta, &content)
|
||||
{
|
||||
posts.push((meta, content, stats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
async fn get_post(&self, name: &str) -> Result<ReturnedPost, PostError> {
|
||||
if self.config.markdown_access && name.ends_with(".md") {
|
||||
let path = self.config.dirs.posts.join(name);
|
||||
|
||||
let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache.remove(name).await;
|
||||
}
|
||||
return Err(PostError::NotFound(name.to_string()));
|
||||
}
|
||||
_ => return Err(PostError::IoError(err)),
|
||||
},
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
|
||||
file.read_to_end(&mut buf).await?;
|
||||
|
||||
Ok(ReturnedPost::Raw(
|
||||
buf,
|
||||
HeaderValue::from_static("text/plain"),
|
||||
))
|
||||
} else {
|
||||
let start = Instant::now();
|
||||
let path = self.config.dirs.posts.join(name.to_owned() + ".md");
|
||||
|
||||
let stat = match tokio::fs::metadata(&path).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache.remove(name).await;
|
||||
}
|
||||
return Err(PostError::NotFound(name.to_string()));
|
||||
}
|
||||
_ => return Err(PostError::IoError(err)),
|
||||
},
|
||||
};
|
||||
let mtime = as_secs(&stat.modified()?);
|
||||
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Some(hit) = cache.lookup(name, mtime, &self.config.render).await
|
||||
{
|
||||
Ok(ReturnedPost::Rendered(
|
||||
hit.metadata,
|
||||
hit.rendered,
|
||||
RenderStats::Cached(start.elapsed()),
|
||||
))
|
||||
} else {
|
||||
let (metadata, rendered, stats) =
|
||||
self.parse_and_render(name.to_string(), path).await?;
|
||||
Ok(ReturnedPost::Rendered(
|
||||
metadata,
|
||||
rendered,
|
||||
RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(&self) {
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache
|
||||
.cleanup(|name| {
|
||||
std::fs::metadata(self.config.dirs.posts.join(name.to_owned() + ".md"))
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.map(|mtime| as_secs(&mtime))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
262
src/post/mod.rs
262
src/post/mod.rs
|
@ -1,14 +1,54 @@
|
|||
pub mod cache;
|
||||
pub mod markdown_posts;
|
||||
|
||||
use std::time::Duration;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use axum::http::HeaderValue;
|
||||
use chrono::{DateTime, Utc};
|
||||
use fronma::parser::{parse, ParsedData};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::PostError;
|
||||
pub use crate::post::markdown_posts::MarkdownPosts;
|
||||
use crate::config::RenderConfig;
|
||||
use crate::markdown_render::render;
|
||||
use crate::post::cache::Cache;
|
||||
use crate::systemtime_as_secs::as_secs;
|
||||
use crate::PostError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FrontMatter {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub author: String,
|
||||
pub icon: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub modified_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl FrontMatter {
|
||||
pub fn into_full(
|
||||
self,
|
||||
name: String,
|
||||
created: Option<SystemTime>,
|
||||
modified: Option<SystemTime>,
|
||||
) -> PostMetadata {
|
||||
PostMetadata {
|
||||
name,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
author: self.author,
|
||||
icon: self.icon,
|
||||
created_at: self.created_at.or_else(|| created.map(|t| t.into())),
|
||||
modified_at: self.modified_at.or_else(|| modified.map(|t| t.into())),
|
||||
tags: self.tags.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct PostMetadata {
|
||||
|
@ -22,40 +62,168 @@ pub struct PostMetadata {
|
|||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub enum RenderStats {
|
||||
Cached(Duration),
|
||||
// format: Total, Parsed in, Rendered in
|
||||
ParsedAndRendered(Duration, Duration, Duration),
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)] // Raw will be returned very rarely
|
||||
pub enum ReturnedPost {
|
||||
Rendered(PostMetadata, String, RenderStats),
|
||||
Raw(Vec<u8>, HeaderValue),
|
||||
#[derive(Clone)]
|
||||
pub struct PostManager {
|
||||
dir: PathBuf,
|
||||
cache: Option<Cache>,
|
||||
config: RenderConfig,
|
||||
}
|
||||
|
||||
pub trait PostManager {
|
||||
async fn get_all_post_metadata(
|
||||
impl PostManager {
|
||||
pub fn new(dir: PathBuf, config: RenderConfig) -> PostManager {
|
||||
PostManager {
|
||||
dir,
|
||||
cache: None,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_cache(dir: PathBuf, config: RenderConfig, cache: Cache) -> PostManager {
|
||||
PostManager {
|
||||
dir,
|
||||
cache: Some(cache),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_and_render(
|
||||
&self,
|
||||
name: String,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<(PostMetadata, String, (Duration, Duration)), PostError> {
|
||||
let parsing_start = Instant::now();
|
||||
let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await {
|
||||
Ok(val) => val,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => return Err(PostError::NotFound(name)),
|
||||
_ => return Err(PostError::IoError(err)),
|
||||
},
|
||||
};
|
||||
let stat = file.metadata().await?;
|
||||
let modified = stat.modified()?;
|
||||
let created = stat.created().ok();
|
||||
|
||||
let mut content = String::with_capacity(stat.len() as usize);
|
||||
file.read_to_string(&mut content).await?;
|
||||
|
||||
let ParsedData { headers, body } = parse::<FrontMatter>(&content)?;
|
||||
let metadata = headers.into_full(name.to_owned(), created, Some(modified));
|
||||
let parsing = parsing_start.elapsed();
|
||||
|
||||
let before_render = Instant::now();
|
||||
let post = render(body, &self.config);
|
||||
let rendering = before_render.elapsed();
|
||||
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache
|
||||
.insert(
|
||||
name.to_string(),
|
||||
metadata.clone(),
|
||||
as_secs(&modified),
|
||||
post.clone(),
|
||||
&self.config,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0))
|
||||
};
|
||||
|
||||
Ok((metadata, post, (parsing, rendering)))
|
||||
}
|
||||
|
||||
pub async fn get_all_post_metadata_filtered(
|
||||
&self,
|
||||
filter: impl Fn(&PostMetadata) -> bool,
|
||||
) -> Result<Vec<PostMetadata>, PostError> {
|
||||
self.get_all_posts(|m, _| filter(m))
|
||||
.await
|
||||
.map(|vec| vec.into_iter().map(|(meta, ..)| meta).collect())
|
||||
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 mtime = as_secs(&stat.modified()?);
|
||||
// TODO. this?
|
||||
let name = path
|
||||
.clone()
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Some(hit) = cache.lookup_metadata(&name, mtime).await
|
||||
&& filter(&hit)
|
||||
{
|
||||
posts.push(hit);
|
||||
} else {
|
||||
match self.parse_and_render(name, path).await {
|
||||
Ok((metadata, ..)) => {
|
||||
if filter(&metadata) {
|
||||
posts.push(metadata);
|
||||
}
|
||||
}
|
||||
Err(err) => match err {
|
||||
PostError::IoError(ref io_err)
|
||||
if matches!(io_err.kind(), io::ErrorKind::NotFound) =>
|
||||
{
|
||||
warn!("TOCTOU: {}", err)
|
||||
}
|
||||
_ => return Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
async fn get_all_posts(
|
||||
pub async fn get_all_posts_filtered(
|
||||
&self,
|
||||
filter: impl Fn(&PostMetadata, &str) -> bool,
|
||||
) -> Result<Vec<(PostMetadata, String, RenderStats)>, PostError>;
|
||||
) -> Result<Vec<(PostMetadata, String, RenderStats)>, PostError> {
|
||||
let mut posts = Vec::new();
|
||||
|
||||
async fn get_max_n_post_metadata_with_optional_tag_sorted(
|
||||
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,
|
||||
n: Option<usize>,
|
||||
tag: Option<&String>,
|
||||
) -> Result<Vec<PostMetadata>, PostError> {
|
||||
let mut posts = self
|
||||
.get_all_post_metadata(|metadata| !tag.is_some_and(|tag| !metadata.tags.contains(tag)))
|
||||
.get_all_post_metadata_filtered(|metadata| {
|
||||
!tag.is_some_and(|tag| !metadata.tags.contains(tag))
|
||||
})
|
||||
.await?;
|
||||
// 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());
|
||||
|
@ -68,15 +236,59 @@ pub trait PostManager {
|
|||
Ok(posts)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
async fn get_post_metadata(&self, name: &str) -> Result<PostMetadata, PostError> {
|
||||
match self.get_post(name).await? {
|
||||
ReturnedPost::Rendered(metadata, ..) => Ok(metadata),
|
||||
ReturnedPost::Raw(..) => Err(PostError::NotFound(name.to_string())),
|
||||
pub async fn get_post(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<(PostMetadata, String, RenderStats), PostError> {
|
||||
let start = Instant::now();
|
||||
let path = self.dir.join(name.to_owned() + ".md");
|
||||
|
||||
let stat = match tokio::fs::metadata(&path).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache.remove(name).await;
|
||||
}
|
||||
return Err(PostError::NotFound(name.to_string()));
|
||||
}
|
||||
_ => return Err(PostError::IoError(err)),
|
||||
},
|
||||
};
|
||||
let mtime = as_secs(&stat.modified()?);
|
||||
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Some(hit) = cache.lookup(name, mtime, &self.config).await
|
||||
{
|
||||
Ok((
|
||||
hit.metadata,
|
||||
hit.rendered,
|
||||
RenderStats::Cached(start.elapsed()),
|
||||
))
|
||||
} else {
|
||||
let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?;
|
||||
Ok((
|
||||
metadata,
|
||||
rendered,
|
||||
RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_post(&self, name: &str) -> Result<ReturnedPost, PostError>;
|
||||
pub fn cache(&self) -> Option<&Cache> {
|
||||
self.cache.as_ref()
|
||||
}
|
||||
|
||||
async fn cleanup(&self);
|
||||
pub async fn cleanup(&self) {
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
cache
|
||||
.cleanup(|name| {
|
||||
std::fs::metadata(self.dir.join(name.to_owned() + ".md"))
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.map(|mtime| as_secs(&mtime))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
for (let el of document.querySelectorAll(".date-rfc3339")) {
|
||||
let date = new Date(Date.parse(el.textContent));
|
||||
el.textContent = date.toLocaleString();
|
||||
}
|
|
@ -84,35 +84,6 @@ div.post {
|
|||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: grid;
|
||||
/*grid-template-columns: auto auto auto;
|
||||
grid-template-rows: auto auto;*/
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table > :not(.value)::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.table > .value {
|
||||
margin-left: 1em;
|
||||
text-align: end;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.table > .created {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.table > .modified {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.table > .tags {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
/* BEGIN cool effect everyone liked */
|
||||
|
||||
body {
|
||||
|
|
|
@ -11,8 +11,6 @@
|
|||
<link rel="stylesheet" href="/static/style.css" />
|
||||
{% if rss %}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ title }}" href="/feed.xml" />
|
||||
{% endif %} {% if js %}
|
||||
<script src="/static/main.js" defer></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,31 +1,19 @@
|
|||
{% macro span_date(value) %}
|
||||
<span class="{%- match df -%}
|
||||
{% when DateFormat::RFC3339 %}
|
||||
date-rfc3339
|
||||
{% when DateFormat::Strftime(_) %}
|
||||
{%- endmatch -%}">{{ value|date(df) }}</span>
|
||||
{% endmacro %}
|
||||
{% macro table(post) %}
|
||||
<div class="table">
|
||||
{% match post.created_at %}
|
||||
{% when Some(created_at) %}
|
||||
<div class="created">written</div>
|
||||
<div class="created value">{% call span_date(created_at) %}</div>
|
||||
written: {{ created_at|date }}<br />
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
{% match post.modified_at %}
|
||||
{% when Some(modified_at) %}
|
||||
<div class="modified">last modified</div>
|
||||
<div class="modified value">{% call span_date(modified_at) %}</div>
|
||||
last modified: {{ modified_at|date }}<br />
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
|
||||
{% if !post.tags.is_empty() %}
|
||||
<div class="tags">tags</div>
|
||||
<div class="tags value">
|
||||
tags:
|
||||
{% for tag in post.tags %}
|
||||
<a href="/?tag={{ tag }}" title="view all posts with this tag">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}<br />
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
|
@ -2,21 +2,20 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{{ meta.title }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
{% match meta.icon %} {% when Some with (url) %}
|
||||
<meta property="og:image" content="{{ url }}" />
|
||||
<link rel="shortcut icon" href="{{ url }}" />
|
||||
{% when None %} {% endmatch %}
|
||||
<title>{{ meta.title }}</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/post.css" />
|
||||
{% if js %}
|
||||
<script src="/static/main.js" defer></script>
|
||||
{% endif %}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{{ meta.title }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
{% match meta.icon %} {% when Some with (url) %}
|
||||
<meta property="og:image" content="{{ url }}" />
|
||||
<link rel="shortcut icon" href="{{ url }}" />
|
||||
{% when None %} {% endmatch %}
|
||||
<title>{{ meta.title }}</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/post.css" />
|
||||
</head>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
@ -25,9 +24,11 @@
|
|||
<span class="post-author">- by {{ meta.author }}</span>
|
||||
</h1>
|
||||
<p class="post-desc">{{ meta.description }}</p>
|
||||
<div class="post">
|
||||
<div class="" post>
|
||||
<!-- prettier-ignore -->
|
||||
<div>
|
||||
{% call macros::table(meta) %}
|
||||
</div>
|
||||
<a href="/posts/{{ meta.name }}">link</a><br />
|
||||
<a href="/">back to home</a>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue