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",
|
"color-eyre",
|
||||||
"comrak",
|
"comrak",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
"derive_more",
|
|
||||||
"fronma",
|
"fronma",
|
||||||
"rss",
|
"rss",
|
||||||
"scc",
|
"scc",
|
||||||
|
@ -487,12 +486,6 @@ dependencies = [
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "convert_case"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
|
@ -592,19 +585,6 @@ dependencies = [
|
||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "deunicode"
|
name = "deunicode"
|
||||||
version = "1.4.4"
|
version = "1.4.4"
|
||||||
|
@ -1455,15 +1435,6 @@ version = "0.1.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
@ -1501,12 +1472,6 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d"
|
checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "semver"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.198"
|
version = "1.0.198"
|
||||||
|
|
|
@ -36,7 +36,6 @@ comrak = { version = "0.22.0", features = [
|
||||||
"syntect",
|
"syntect",
|
||||||
], default-features = false }
|
], default-features = false }
|
||||||
console-subscriber = { version = "0.2.0", optional = true }
|
console-subscriber = { version = "0.2.0", optional = true }
|
||||||
derive_more = "0.99.17"
|
|
||||||
fronma = "0.2.0"
|
fronma = "0.2.0"
|
||||||
rss = "2.0.7"
|
rss = "2.0.7"
|
||||||
scc = { version = "2.1.0", features = ["serde"] }
|
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)
|
- [ ] ^ 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
|
- [x] (de)compress cache with zstd on startup/shutdown
|
||||||
- [ ] make date parsing less strict
|
- [ ] make date parsing less strict
|
||||||
- [x] make date formatting better
|
- [ ] make date formatting better
|
||||||
- [ ] date formatting respects user timezone
|
- [ ] date formatting respects user timezone
|
||||||
- [x] clean up imports and require less features
|
- [x] clean up imports and require less features
|
||||||
- [ ] improve home page
|
- [ ] 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
|
the default configuration with comments looks like this
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
title = "bingus-blog" # title of the blog
|
title = "bingus-blog" # title of the website
|
||||||
# description of the blog
|
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"
|
raw_access = true # allow users to see the raw markdown of a post
|
||||||
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)
|
|
||||||
|
|
||||||
[rss]
|
[rss]
|
||||||
enable = false # serve an rss field under /feed.xml
|
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::env;
|
||||||
use std::net::{IpAddr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use color_eyre::eyre::{bail, Context, Result};
|
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, instrument};
|
use tracing::{error, info};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::ranged_i128_visitor::RangedI128Visitor;
|
use crate::ranged_i128_visitor::RangedI128Visitor;
|
||||||
|
@ -49,8 +49,6 @@ pub struct HttpConfig {
|
||||||
pub struct DirsConfig {
|
pub struct DirsConfig {
|
||||||
pub posts: PathBuf,
|
pub posts: PathBuf,
|
||||||
pub media: PathBuf,
|
pub media: PathBuf,
|
||||||
#[serde(rename = "static")]
|
|
||||||
pub _static: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
@ -59,22 +57,13 @@ pub struct RssConfig {
|
||||||
pub link: Url,
|
pub link: Url,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
|
||||||
pub enum DateFormat {
|
|
||||||
#[default]
|
|
||||||
RFC3339,
|
|
||||||
#[serde(untagged)]
|
|
||||||
Strftime(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub markdown_access: bool,
|
pub raw_access: bool,
|
||||||
pub date_format: DateFormat,
|
pub num_posts: usize,
|
||||||
pub js_enable: bool,
|
|
||||||
pub rss: RssConfig,
|
pub rss: RssConfig,
|
||||||
pub dirs: DirsConfig,
|
pub dirs: DirsConfig,
|
||||||
pub http: HttpConfig,
|
pub http: HttpConfig,
|
||||||
|
@ -87,9 +76,8 @@ impl Default for Config {
|
||||||
Self {
|
Self {
|
||||||
title: "bingus-blog".into(),
|
title: "bingus-blog".into(),
|
||||||
description: "blazingly fast markdown blog software written in rust memory safe".into(),
|
description: "blazingly fast markdown blog software written in rust memory safe".into(),
|
||||||
markdown_access: true,
|
raw_access: true,
|
||||||
date_format: Default::default(),
|
num_posts: 5,
|
||||||
js_enable: true,
|
|
||||||
// i have a love-hate relationship with serde
|
// i have a love-hate relationship with serde
|
||||||
// it was engimatic at first, but then i started actually using it
|
// it was engimatic at first, but then i started actually using it
|
||||||
// writing my own serialize and deserialize implementations.. spending
|
// writing my own serialize and deserialize implementations.. spending
|
||||||
|
@ -113,7 +101,6 @@ impl Default for DirsConfig {
|
||||||
Self {
|
Self {
|
||||||
posts: "posts".into(),
|
posts: "posts".into(),
|
||||||
media: "media".into(),
|
media: "media".into(),
|
||||||
_static: "static".into(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,7 +108,7 @@ impl Default for DirsConfig {
|
||||||
impl Default for HttpConfig {
|
impl Default for HttpConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
host: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
host: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,7 +138,6 @@ impl Default for CacheConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(name = "config")]
|
|
||||||
pub async fn load() -> Result<Config> {
|
pub async fn load() -> Result<Config> {
|
||||||
let config_file = env::var(format!(
|
let config_file = env::var(format!(
|
||||||
"{}_CONFIG",
|
"{}_CONFIG",
|
||||||
|
|
|
@ -1,29 +1,11 @@
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, time::Duration};
|
||||||
use std::fmt::Display;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use chrono::{DateTime, TimeZone};
|
use chrono::{DateTime, TimeZone};
|
||||||
|
|
||||||
use crate::config::DateFormat;
|
|
||||||
use crate::post::PostMetadata;
|
use crate::post::PostMetadata;
|
||||||
|
|
||||||
fn format_date<T>(date: &DateTime<T>, date_format: &DateFormat) -> String
|
pub fn date<T: TimeZone>(date: &DateTime<T>) -> Result<String, askama::Error> {
|
||||||
where
|
Ok(date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||||
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 duration(duration: &&Duration) -> Result<String, askama::Error> {
|
pub fn duration(duration: &&Duration) -> Result<String, askama::Error> {
|
||||||
|
|
322
src/main.rs
322
src/main.rs
|
@ -1,6 +1,5 @@
|
||||||
#![feature(let_chains)]
|
#![feature(let_chains)]
|
||||||
|
|
||||||
mod app;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod filters;
|
mod filters;
|
||||||
|
@ -11,23 +10,184 @@ mod ranged_i128_visitor;
|
||||||
mod systemtime_as_secs;
|
mod systemtime_as_secs;
|
||||||
|
|
||||||
use std::future::IntoFuture;
|
use std::future::IntoFuture;
|
||||||
|
use std::io::Read;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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 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::net::TcpListener;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::{select, signal};
|
use tokio::{select, signal};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, error, info, info_span, warn, Span};
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
use tracing_subscriber::{util::SubscriberInitExt, EnvFilter};
|
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::config::Config;
|
||||||
use crate::post::{MarkdownPosts, PostManager};
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> eyre::Result<()> {
|
async fn main() -> eyre::Result<()> {
|
||||||
|
@ -44,26 +204,89 @@ async fn main() -> eyre::Result<()> {
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let config = Arc::new(
|
let config = config::load()
|
||||||
config::load()
|
|
||||||
.await
|
.await
|
||||||
.context("couldn't load configuration")?,
|
.context("couldn't load configuration")?;
|
||||||
);
|
|
||||||
|
|
||||||
let socket_addr = SocketAddr::new(config.http.host, config.http.port);
|
let socket_addr = SocketAddr::new(config.http.host, config.http.port);
|
||||||
|
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
let cancellation_token = CancellationToken::new();
|
let cancellation_token = CancellationToken::new();
|
||||||
|
|
||||||
let posts = Arc::new(MarkdownPosts::new(Arc::clone(&config)).await?);
|
let posts = if config.cache.enable {
|
||||||
let state = AppState {
|
if config.cache.persistence
|
||||||
config: Arc::clone(&config),
|
&& tokio::fs::try_exists(&config.cache.file)
|
||||||
posts: Arc::clone(&posts),
|
.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();
|
||||||
};
|
};
|
||||||
|
|
||||||
if config.cache.enable && config.cache.cleanup {
|
Ok::<PostManager, color_eyre::Report>(PostManager::new_with_cache(
|
||||||
if let Some(t) = config.cache.cleanup_interval {
|
config.dirs.posts.clone(),
|
||||||
let posts = Arc::clone(&posts);
|
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())
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
let token = cancellation_token.child_token();
|
||||||
debug!("setting up cleanup task");
|
debug!("setting up cleanup task");
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
|
@ -72,17 +295,45 @@ async fn main() -> eyre::Result<()> {
|
||||||
select! {
|
select! {
|
||||||
_ = token.cancelled() => break,
|
_ = token.cancelled() => break,
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {
|
||||||
posts.cleanup().await
|
state.posts.cleanup().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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)
|
let listener = TcpListener::bind(socket_addr)
|
||||||
.await
|
.await
|
||||||
|
@ -130,7 +381,36 @@ async fn main() -> eyre::Result<()> {
|
||||||
task.context("failed to join task")?;
|
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>(())
|
Ok::<(), color_eyre::Report>(())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
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 scc::HashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
use crate::config::RenderConfig;
|
||||||
|
use crate::post::PostMetadata;
|
||||||
|
|
||||||
/// do not persist cache if this version number changed
|
/// do not persist cache if this version number changed
|
||||||
pub const CACHE_VERSION: u16 = 2;
|
pub const CACHE_VERSION: u16 = 2;
|
||||||
|
|
||||||
|
@ -135,29 +133,3 @@ impl Cache {
|
||||||
self.1
|
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 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 chrono::{DateTime, Utc};
|
||||||
|
use fronma::parser::{parse, ParsedData};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::error::PostError;
|
use crate::config::RenderConfig;
|
||||||
pub use crate::post::markdown_posts::MarkdownPosts;
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct PostMetadata {
|
pub struct PostMetadata {
|
||||||
|
@ -22,40 +62,168 @@ pub struct PostMetadata {
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
pub enum RenderStats {
|
pub enum RenderStats {
|
||||||
Cached(Duration),
|
Cached(Duration),
|
||||||
// format: Total, Parsed in, Rendered in
|
// format: Total, Parsed in, Rendered in
|
||||||
ParsedAndRendered(Duration, Duration, Duration),
|
ParsedAndRendered(Duration, Duration, Duration),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)] // Raw will be returned very rarely
|
#[derive(Clone)]
|
||||||
pub enum ReturnedPost {
|
pub struct PostManager {
|
||||||
Rendered(PostMetadata, String, RenderStats),
|
dir: PathBuf,
|
||||||
Raw(Vec<u8>, HeaderValue),
|
cache: Option<Cache>,
|
||||||
|
config: RenderConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait PostManager {
|
impl PostManager {
|
||||||
async fn get_all_post_metadata(
|
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,
|
&self,
|
||||||
filter: impl Fn(&PostMetadata) -> bool,
|
filter: impl Fn(&PostMetadata) -> bool,
|
||||||
) -> Result<Vec<PostMetadata>, PostError> {
|
) -> Result<Vec<PostMetadata>, PostError> {
|
||||||
self.get_all_posts(|m, _| filter(m))
|
let mut posts = Vec::new();
|
||||||
.await
|
|
||||||
.map(|vec| vec.into_iter().map(|(meta, ..)| meta).collect())
|
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_all_posts(
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_posts_filtered(
|
||||||
&self,
|
&self,
|
||||||
filter: impl Fn(&PostMetadata, &str) -> bool,
|
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,
|
&self,
|
||||||
n: Option<usize>,
|
n: Option<usize>,
|
||||||
tag: Option<&String>,
|
tag: Option<&String>,
|
||||||
) -> Result<Vec<PostMetadata>, PostError> {
|
) -> Result<Vec<PostMetadata>, PostError> {
|
||||||
let mut posts = self
|
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?;
|
.await?;
|
||||||
// we still want some semblance of order if created_at is None so sort by mtime as well
|
// 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_unstable_by_key(|metadata| metadata.modified_at.unwrap_or_default());
|
||||||
|
@ -68,15 +236,59 @@ pub trait PostManager {
|
||||||
Ok(posts)
|
Ok(posts)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
pub async fn get_post(
|
||||||
async fn get_post_metadata(&self, name: &str) -> Result<PostMetadata, PostError> {
|
&self,
|
||||||
match self.get_post(name).await? {
|
name: &str,
|
||||||
ReturnedPost::Rendered(metadata, ..) => Ok(metadata),
|
) -> Result<(PostMetadata, String, RenderStats), PostError> {
|
||||||
ReturnedPost::Raw(..) => Err(PostError::NotFound(name.to_string())),
|
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;
|
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 */
|
/* BEGIN cool effect everyone liked */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
|
@ -11,8 +11,6 @@
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
{% if rss %}
|
{% if rss %}
|
||||||
<link rel="alternate" type="application/rss+xml" title="{{ title }}" href="/feed.xml" />
|
<link rel="alternate" type="application/rss+xml" title="{{ title }}" href="/feed.xml" />
|
||||||
{% endif %} {% if js %}
|
|
||||||
<script src="/static/main.js" defer></script>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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) %}
|
{% macro table(post) %}
|
||||||
<div class="table">
|
|
||||||
{% match post.created_at %}
|
{% match post.created_at %}
|
||||||
{% when Some(created_at) %}
|
{% when Some(created_at) %}
|
||||||
<div class="created">written</div>
|
written: {{ created_at|date }}<br />
|
||||||
<div class="created value">{% call span_date(created_at) %}</div>
|
|
||||||
{% when None %}
|
{% when None %}
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
{% match post.modified_at %}
|
{% match post.modified_at %}
|
||||||
{% when Some(modified_at) %}
|
{% when Some(modified_at) %}
|
||||||
<div class="modified">last modified</div>
|
last modified: {{ modified_at|date }}<br />
|
||||||
<div class="modified value">{% call span_date(modified_at) %}</div>
|
|
||||||
{% when None %}
|
{% when None %}
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
|
||||||
{% if !post.tags.is_empty() %}
|
{% if !post.tags.is_empty() %}
|
||||||
<div class="tags">tags</div>
|
tags:
|
||||||
<div class="tags value">
|
|
||||||
{% for tag in post.tags %}
|
{% for tag in post.tags %}
|
||||||
<a href="/?tag={{ tag }}" title="view all posts with this tag">{{ tag }}</a>
|
<a href="/?tag={{ tag }}" title="view all posts with this tag">{{ tag }}</a>
|
||||||
{% endfor %}
|
{% endfor %}<br />
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{%- import "macros.askama" as macros -%}
|
{%- import "macros.askama" as macros -%}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
@ -14,9 +15,7 @@
|
||||||
<title>{{ meta.title }}</title>
|
<title>{{ meta.title }}</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
<link rel="stylesheet" href="/static/post.css" />
|
<link rel="stylesheet" href="/static/post.css" />
|
||||||
{% if js %}
|
</head>
|
||||||
<script src="/static/main.js" defer></script>
|
|
||||||
{% endif %}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
@ -25,9 +24,11 @@
|
||||||
<span class="post-author">- by {{ meta.author }}</span>
|
<span class="post-author">- by {{ meta.author }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="post-desc">{{ meta.description }}</p>
|
<p class="post-desc">{{ meta.description }}</p>
|
||||||
<div class="post">
|
<div class="" post>
|
||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
|
<div>
|
||||||
{% call macros::table(meta) %}
|
{% call macros::table(meta) %}
|
||||||
|
</div>
|
||||||
<a href="/posts/{{ meta.name }}">link</a><br />
|
<a href="/posts/{{ meta.name }}">link</a><br />
|
||||||
<a href="/">back to home</a>
|
<a href="/">back to home</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue