From 385d25c9f96c0acd5d932488e3bd0ed36ceb4dd7 Mon Sep 17 00:00:00 2001 From: Christine Dodrill Date: Thu, 16 Jul 2020 15:32:30 -0400 Subject: Rewrite site backend in Rust (#178) * add shell.nix changes for Rust #176 * set up base crate layout * add first set of dependencies * start adding basic app modules * start html templates * serve index page * add contact and feeds pages * add resume rendering support * resume cleanups * get signalboost page working * rewrite config to be in dhall * more work * basic generic post loading * more tests * initial blog index support * fix routing? * render blogposts * X-Clacks-Overhead * split blog handlers into blog.rs * gallery index * gallery posts * fix hashtags * remove instantpage (it messes up the metrics) * talk support + prometheus * Create rust.yml * Update rust.yml * Update codeql-analysis.yml * add jsonfeed library * jsonfeed support * rss/atom * go mod tidy * atom: add posted date * rss: add publishing date * nix: build rust program * rip out go code * rip out go templates * prepare for serving in docker * create kubernetes deployment * create automagic deployment * build docker images on non-master * more fixes * fix timestamps * fix RSS/Atom/JSONFeed validation errors * add go vanity import redirecting * templates/header: remove this * atom feed: fixes * fix? * fix?? * fix rust tests * Update rust.yml * automatically show snow during the winter * fix dates * show commit link in footer * sitemap support * fix compiler warning * start basic patreon client * integrate kankyo * fix patreon client * add patrons page * remove this * handle patron errors better * fix build * clean up deploy * sort envvars for deploy * remove deps.nix * shell.nix: remove go * update README * fix envvars for tests * nice * blog: add rewrite in rust post * blog/site-update: more words --- src/app.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++++ src/build.rs | 11 +++ src/handlers/blog.rs | 77 +++++++++++++++++++ src/handlers/feeds.rs | 73 ++++++++++++++++++ src/handlers/gallery.rs | 40 ++++++++++ src/handlers/mod.rs | 145 ++++++++++++++++++++++++++++++++++++ src/handlers/talks.rs | 40 ++++++++++ src/main.rs | 154 ++++++++++++++++++++++++++++++++++++++ src/post/frontmatter.rs | 114 +++++++++++++++++++++++++++++ src/post/mod.rs | 178 ++++++++++++++++++++++++++++++++++++++++++++ src/signalboost.rs | 23 ++++++ 11 files changed, 1046 insertions(+) create mode 100644 src/app.rs create mode 100644 src/build.rs create mode 100644 src/handlers/blog.rs create mode 100644 src/handlers/feeds.rs create mode 100644 src/handlers/gallery.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/talks.rs create mode 100644 src/main.rs create mode 100644 src/post/frontmatter.rs create mode 100644 src/post/mod.rs create mode 100644 src/signalboost.rs (limited to 'src') diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..7b5b377 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,191 @@ +use crate::{post::Post, signalboost::Person}; +use anyhow::Result; +use atom_syndication as atom; +use comrak::{markdown_to_html, ComrakOptions}; +use serde::Deserialize; +use std::{fs, path::PathBuf}; + +#[derive(Clone, Deserialize)] +pub struct Config { + #[serde(rename = "clackSet")] + clack_set: Vec, + signalboost: Vec, + port: u16, + #[serde(rename = "resumeFname")] + resume_fname: PathBuf, +} + +pub fn markdown(inp: &str) -> String { + let mut options = ComrakOptions::default(); + + options.extension.autolink = true; + options.extension.table = true; + options.extension.description_lists = true; + options.extension.superscript = true; + options.extension.strikethrough = true; + options.extension.footnotes = true; + + options.render.unsafe_ = true; + + markdown_to_html(inp, &options) +} + +async fn patrons() -> Result> { + use patreon::*; + let creds: Credentials = envy::prefixed("PATREON_").from_env().unwrap(); + let cli = Client::new(creds); + + match cli.campaign().await { + Ok(camp) => { + let id = camp.data[0].id.clone(); + + match cli.pledges(id).await { + Ok(users) => Ok(Some(users)), + Err(why) => { + log::error!("error getting pledges: {:?}", why); + Ok(None) + } + } + } + Err(why) => { + log::error!("error getting patreon campaign: {:?}", why); + Ok(None) + } + } +} + +pub const ICON: &'static str = "https://christine.website/static/img/avatar.png"; + +pub struct State { + pub cfg: Config, + pub signalboost: Vec, + pub resume: String, + pub blog: Vec, + pub gallery: Vec, + pub talks: Vec, + pub everything: Vec, + pub jf: jsonfeed::Feed, + pub rf: rss::Channel, + pub af: atom::Feed, + pub sitemap: Vec, + pub patrons: Option, +} + +pub async fn init(cfg: PathBuf) -> Result { + let cfg: Config = serde_dhall::from_file(cfg).parse()?; + let sb = cfg.signalboost.clone(); + let resume = fs::read_to_string(cfg.resume_fname.clone())?; + let resume: String = markdown(&resume); + let blog = crate::post::load("blog")?; + let gallery = crate::post::load("gallery")?; + let talks = crate::post::load("talks")?; + let mut everything: Vec = vec![]; + + { + let blog = blog.clone(); + let gallery = gallery.clone(); + let talks = talks.clone(); + everything.extend(blog.iter().cloned()); + everything.extend(gallery.iter().cloned()); + everything.extend(talks.iter().cloned()); + }; + + everything.sort(); + everything.reverse(); + + let mut ri: Vec = vec![]; + let mut ai: Vec = vec![]; + + let mut jfb = jsonfeed::Feed::builder() + .title("Christine Dodrill's Blog") + .description("My blog posts and rants about various technology things.") + .author( + jsonfeed::Author::new() + .name("Christine Dodrill") + .url("https://christine.website") + .avatar(ICON), + ) + .feed_url("https://christine.website/blog.json") + .user_comment("This is a JSON feed of my blogposts. For more information read: https://jsonfeed.org/version/1") + .home_page_url("https://christine.website") + .icon(ICON) + .favicon(ICON); + + for post in &everything { + let post = post.clone(); + jfb = jfb.item(post.clone().into()); + ri.push(post.clone().into()); + ai.push(post.clone().into()); + } + + let af = { + let mut af = atom::FeedBuilder::default(); + af.title("Christine Dodrill's Blog"); + af.id("https://christine.website/blog"); + af.generator({ + let mut generator = atom::Generator::default(); + generator.set_value(env!("CARGO_PKG_NAME")); + generator.set_version(env!("CARGO_PKG_VERSION").to_string()); + generator.set_uri("https://github.com/Xe/site".to_string()); + + generator + }); + af.entries(ai); + + af.build().unwrap() + }; + + let rf = { + let mut rf = rss::ChannelBuilder::default(); + rf.title("Christine Dodrill's Blog"); + rf.link("https://christine.website/blog"); + rf.generator(crate::APPLICATION_NAME.to_string()); + rf.items(ri); + + rf.build().unwrap() + }; + + let mut sm: Vec = vec![]; + let smw = sitemap::writer::SiteMapWriter::new(&mut sm); + let mut urlwriter = smw.start_urlset()?; + for url in &[ + "https://christine.website/resume", + "https://christine.website/contact", + "https://christine.website/", + "https://christine.website/blog", + "https://christine.website/signalboost", + ] { + urlwriter.url(*url)?; + } + + for post in &everything { + urlwriter.url(format!("https://christine.website/{}", post.link))?; + } + + urlwriter.end()?; + + Ok(State { + cfg: cfg, + signalboost: sb, + resume: resume, + blog: blog, + gallery: gallery, + talks: talks, + everything: everything, + jf: jfb.build(), + af: af, + rf: rf, + sitemap: sm, + patrons: patrons().await?, + }) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + #[tokio::test] + async fn init() -> Result<()> { + super::init("./config.dhall".into()).await?; + Ok(()) + } +} diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..3b73241 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,11 @@ +use ructe::{Result, Ructe}; +use std::process::Command; + +fn main() -> Result<()> { + Ructe::from_env()?.compile_templates("templates")?; + + let output = Command::new("git").args(&["rev-parse", "HEAD"]).output().unwrap(); + let git_hash = String::from_utf8(output.stdout).unwrap(); + println!("cargo:rustc-env=GITHUB_SHA={}", git_hash); + Ok(()) +} diff --git a/src/handlers/blog.rs b/src/handlers/blog.rs new file mode 100644 index 0000000..e494e04 --- /dev/null +++ b/src/handlers/blog.rs @@ -0,0 +1,77 @@ +use super::{PostNotFound, SeriesNotFound}; +use crate::{ + app::State, + post::Post, + templates::{self, Html, RenderRucte}, +}; +use lazy_static::lazy_static; +use prometheus::{IntCounterVec, register_int_counter_vec, opts}; +use std::sync::Arc; +use warp::{http::Response, Rejection, Reply}; + +lazy_static! { + static ref HIT_COUNTER: IntCounterVec = + register_int_counter_vec!(opts!("blogpost_hits", "Number of hits to blogposts"), &["name"]) + .unwrap(); +} + +pub async fn index(state: Arc) -> Result { + let state = state.clone(); + Response::builder().html(|o| templates::blogindex_html(o, state.blog.clone())) +} + +pub async fn series(state: Arc) -> Result { + let state = state.clone(); + let mut series: Vec = vec![]; + + for post in &state.blog { + if post.front_matter.series.is_some() { + series.push(post.front_matter.series.as_ref().unwrap().clone()); + } + } + + series.sort(); + series.dedup(); + + Response::builder().html(|o| templates::series_html(o, series)) +} + +pub async fn series_view(series: String, state: Arc) -> Result { + let state = state.clone(); + let mut posts: Vec = vec![]; + + for post in &state.blog { + if post.front_matter.series.is_none() { + continue; + } + if post.front_matter.series.as_ref().unwrap() != &series { + continue; + } + posts.push(post.clone()); + } + + if posts.len() == 0 { + Err(SeriesNotFound(series).into()) + } else { + Response::builder().html(|o| templates::series_posts_html(o, series, &posts)) + } +} + +pub async fn post_view(name: String, state: Arc) -> Result { + let mut want: Option = None; + + for post in &state.blog { + if post.link == format!("blog/{}", name) { + want = Some(post.clone()); + } + } + + match want { + None => Err(PostNotFound("blog".into(), name).into()), + Some(post) => { + HIT_COUNTER.with_label_values(&[name.clone().as_str()]).inc(); + let body = Html(post.body_html.clone()); + Response::builder().html(|o| templates::blogpost_html(o, post, body)) + } + } +} diff --git a/src/handlers/feeds.rs b/src/handlers/feeds.rs new file mode 100644 index 0000000..7d7db32 --- /dev/null +++ b/src/handlers/feeds.rs @@ -0,0 +1,73 @@ +use crate::app::State; +use lazy_static::lazy_static; +use prometheus::{opts, register_int_counter_vec, IntCounterVec}; +use std::sync::Arc; +use warp::{http::Response, Rejection, Reply}; + +lazy_static! { + static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!( + opts!("feed_hits", "Number of hits to various feeds"), + &["kind"] + ) + .unwrap(); +} + +pub async fn jsonfeed(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["json"]).inc(); + let state = state.clone(); + Ok(warp::reply::json(&state.jf)) +} + +#[derive(Debug)] +pub enum RenderError { + WriteAtom(atom_syndication::Error), + WriteRss(rss::Error), + Build(warp::http::Error), +} + +impl warp::reject::Reject for RenderError {} + +pub async fn atom(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["atom"]).inc(); + let state = state.clone(); + let mut buf = Vec::new(); + state + .af + .write_to(&mut buf) + .map_err(RenderError::WriteAtom) + .map_err(warp::reject::custom)?; + Response::builder() + .status(200) + .header("Content-Type", "application/atom+xml") + .body(buf) + .map_err(RenderError::Build) + .map_err(warp::reject::custom) +} + +pub async fn rss(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["rss"]).inc(); + let state = state.clone(); + let mut buf = Vec::new(); + state + .rf + .write_to(&mut buf) + .map_err(RenderError::WriteRss) + .map_err(warp::reject::custom)?; + Response::builder() + .status(200) + .header("Content-Type", "application/rss+xml") + .body(buf) + .map_err(RenderError::Build) + .map_err(warp::reject::custom) +} + +pub async fn sitemap(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["sitemap"]).inc(); + let state = state.clone(); + Response::builder() + .status(200) + .header("Content-Type", "application/xml") + .body(state.sitemap.clone()) + .map_err(RenderError::Build) + .map_err(warp::reject::custom) +} diff --git a/src/handlers/gallery.rs b/src/handlers/gallery.rs new file mode 100644 index 0000000..2094ab2 --- /dev/null +++ b/src/handlers/gallery.rs @@ -0,0 +1,40 @@ +use super::PostNotFound; +use crate::{ + app::State, + post::Post, + templates::{self, Html, RenderRucte}, +}; +use lazy_static::lazy_static; +use prometheus::{IntCounterVec, register_int_counter_vec, opts}; +use std::sync::Arc; +use warp::{http::Response, Rejection, Reply}; + +lazy_static! { + static ref HIT_COUNTER: IntCounterVec = + register_int_counter_vec!(opts!("gallery_hits", "Number of hits to gallery images"), &["name"]) + .unwrap(); +} + +pub async fn index(state: Arc) -> Result { + let state = state.clone(); + Response::builder().html(|o| templates::galleryindex_html(o, state.gallery.clone())) +} + +pub async fn post_view(name: String, state: Arc) -> Result { + let mut want: Option = None; + + for post in &state.gallery { + if post.link == format!("gallery/{}", name) { + want = Some(post.clone()); + } + } + + match want { + None => Err(PostNotFound("gallery".into(), name).into()), + Some(post) => { + HIT_COUNTER.with_label_values(&[name.clone().as_str()]).inc(); + let body = Html(post.body_html.clone()); + Response::builder().html(|o| templates::gallerypost_html(o, post, body)) + } + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..5c51352 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,145 @@ +use crate::{ + app::State, + templates::{self, Html, RenderRucte}, +}; +use lazy_static::lazy_static; +use prometheus::{opts, register_int_counter_vec, IntCounterVec}; +use std::{convert::Infallible, fmt, sync::Arc}; +use warp::{ + http::{Response, StatusCode}, + Rejection, Reply, +}; + +lazy_static! { + static ref HIT_COUNTER: IntCounterVec = + register_int_counter_vec!(opts!("hits", "Number of hits to various pages"), &["page"]) + .unwrap(); +} + +pub async fn index() -> Result { + HIT_COUNTER.with_label_values(&["index"]).inc(); + Response::builder().html(|o| templates::index_html(o)) +} + +pub async fn contact() -> Result { + HIT_COUNTER.with_label_values(&["contact"]).inc(); + Response::builder().html(|o| templates::contact_html(o)) +} + +pub async fn feeds() -> Result { + HIT_COUNTER.with_label_values(&["feeds"]).inc(); + Response::builder().html(|o| templates::feeds_html(o)) +} + +pub async fn resume(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["resume"]).inc(); + let state = state.clone(); + Response::builder().html(|o| templates::resume_html(o, Html(state.resume.clone()))) +} + +pub async fn patrons(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["patrons"]).inc(); + let state = state.clone(); + match &state.patrons { + None => Response::builder().status(500).html(|o| { + templates::error_html( + o, + "Could not load patrons, let me know the API token expired again".to_string(), + ) + }), + Some(patrons) => Response::builder().html(|o| templates::patrons_html(o, patrons.clone())), + } +} + +pub async fn signalboost(state: Arc) -> Result { + HIT_COUNTER.with_label_values(&["signalboost"]).inc(); + let state = state.clone(); + Response::builder().html(|o| templates::signalboost_html(o, state.signalboost.clone())) +} + +pub async fn not_found() -> Result { + HIT_COUNTER.with_label_values(&["not_found"]).inc(); + Response::builder().html(|o| templates::notfound_html(o, "some path".into())) +} + +pub mod blog; +pub mod feeds; +pub mod gallery; +pub mod talks; + +#[derive(Debug, thiserror::Error)] +struct PostNotFound(String, String); + +impl fmt::Display for PostNotFound { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "not found: {}/{}", self.0, self.1) + } +} + +impl warp::reject::Reject for PostNotFound {} + +impl From for warp::reject::Rejection { + fn from(error: PostNotFound) -> Self { + warp::reject::custom(error) + } +} + +#[derive(Debug, thiserror::Error)] +struct SeriesNotFound(String); + +impl fmt::Display for SeriesNotFound { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl warp::reject::Reject for SeriesNotFound {} + +impl From for warp::reject::Rejection { + fn from(error: SeriesNotFound) -> Self { + warp::reject::custom(error) + } +} + +lazy_static! { + static ref REJECTION_COUNTER: IntCounterVec = register_int_counter_vec!( + opts!("rejections", "Number of rejections by kind"), + &["kind"] + ) + .unwrap(); +} + +pub async fn rejection(err: Rejection) -> Result { + let path: String; + let code; + + if err.is_not_found() { + REJECTION_COUNTER.with_label_values(&["404"]).inc(); + path = "".into(); + code = StatusCode::NOT_FOUND; + } else if let Some(SeriesNotFound(series)) = err.find() { + REJECTION_COUNTER + .with_label_values(&["SeriesNotFound"]) + .inc(); + log::error!("invalid series {}", series); + path = format!("/blog/series/{}", series); + code = StatusCode::NOT_FOUND; + } else if let Some(PostNotFound(kind, name)) = err.find() { + REJECTION_COUNTER.with_label_values(&["PostNotFound"]).inc(); + log::error!("unknown post {}/{}", kind, name); + path = format!("/{}/{}", kind, name); + code = StatusCode::NOT_FOUND; + } else { + REJECTION_COUNTER.with_label_values(&["Other"]).inc(); + log::error!("unhandled rejection: {:?}", err); + path = format!("weird rejection: {:?}", err); + code = StatusCode::INTERNAL_SERVER_ERROR; + } + + Ok(warp::reply::with_status( + Response::builder() + .html(|o| templates::notfound_html(o, path)) + .unwrap(), + code, + )) +} diff --git a/src/handlers/talks.rs b/src/handlers/talks.rs new file mode 100644 index 0000000..54f1e64 --- /dev/null +++ b/src/handlers/talks.rs @@ -0,0 +1,40 @@ +use super::PostNotFound; +use crate::{ + app::State, + post::Post, + templates::{self, Html, RenderRucte}, +}; +use lazy_static::lazy_static; +use prometheus::{IntCounterVec, register_int_counter_vec, opts}; +use std::sync::Arc; +use warp::{http::Response, Rejection, Reply}; + +lazy_static! { + static ref HIT_COUNTER: IntCounterVec = + register_int_counter_vec!(opts!("talks_hits", "Number of hits to talks images"), &["name"]) + .unwrap(); +} + +pub async fn index(state: Arc) -> Result { + let state = state.clone(); + Response::builder().html(|o| templates::talkindex_html(o, state.talks.clone())) +} + +pub async fn post_view(name: String, state: Arc) -> Result { + let mut want: Option = None; + + for post in &state.talks { + if post.link == format!("talks/{}", name) { + want = Some(post.clone()); + } + } + + match want { + None => Err(PostNotFound("talks".into(), name).into()), + Some(post) => { + HIT_COUNTER.with_label_values(&[name.clone().as_str()]).inc(); + let body = Html(post.body_html.clone()); + Response::builder().html(|o| templates::talkpost_html(o, post, body)) + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..aa5400e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,154 @@ +use anyhow::Result; +use hyper::{header::CONTENT_TYPE, Body, Response}; +use prometheus::{Encoder, TextEncoder}; +use std::sync::Arc; +use warp::{path, Filter}; + +pub mod app; +pub mod handlers; +pub mod post; +pub mod signalboost; + +use app::State; + +const APPLICATION_NAME: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +fn with_state( + state: Arc, +) -> impl Filter,), Error = std::convert::Infallible> + Clone { + warp::any().map(move || state.clone()) +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = kankyo::init(); + pretty_env_logger::init(); + log::info!("starting up commit {}", env!("GITHUB_SHA")); + + let state = Arc::new(app::init( + std::env::var("CONFIG_FNAME") + .unwrap_or("./config.dhall".into()) + .as_str() + .into(), + ).await?); + + let healthcheck = warp::get().and(warp::path(".within").and(warp::path("health")).map(|| "OK")); + + let base = warp::path!("blog" / ..); + let blog_index = base + .and(warp::path::end()) + .and(with_state(state.clone())) + .and_then(handlers::blog::index); + let series = base + .and(warp::path!("series").and(with_state(state.clone()).and_then(handlers::blog::series))); + let series_view = base.and( + warp::path!("series" / String) + .and(with_state(state.clone())) + .and(warp::get()) + .and_then(handlers::blog::series_view), + ); + let post_view = base.and( + warp::path!(String) + .and(with_state(state.clone())) + .and(warp::get()) + .and_then(handlers::blog::post_view), + ); + + let gallery_base = warp::path!("gallery" / ..); + let gallery_index = gallery_base + .and(warp::path::end()) + .and(with_state(state.clone())) + .and_then(handlers::gallery::index); + let gallery_post_view = gallery_base.and( + warp::path!(String) + .and(with_state(state.clone())) + .and(warp::get()) + .and_then(handlers::gallery::post_view), + ); + + let talk_base = warp::path!("talks" / ..); + let talk_index = talk_base + .and(warp::path::end()) + .and(with_state(state.clone())) + .and_then(handlers::talks::index); + let talk_post_view = talk_base.and( + warp::path!(String) + .and(with_state(state.clone())) + .and(warp::get()) + .and_then(handlers::talks::post_view), + ); + + let index = warp::get().and(path::end().and_then(handlers::index)); + let contact = warp::path!("contact").and_then(handlers::contact); + let feeds = warp::path!("feeds").and_then(handlers::feeds); + let resume = warp::path!("resume") + .and(with_state(state.clone())) + .and_then(handlers::resume); + let signalboost = warp::path!("signalboost") + .and(with_state(state.clone())) + .and_then(handlers::signalboost); + let patrons = warp::path!("patrons") + .and(with_state(state.clone())) + .and_then(handlers::patrons); + + let files = warp::path("static").and(warp::fs::dir("./static")); + let css = warp::path("css").and(warp::fs::dir("./css")); + let sw = warp::path("sw.js").and(warp::fs::file("./static/js/sw.js")); + let robots = warp::path("robots.txt").and(warp::fs::file("./static/robots.txt")); + let favicon = warp::path("favicon.ico").and(warp::fs::file("./static/favicon/favicon.ico")); + + let jsonfeed = warp::path("blog.json") + .and(with_state(state.clone())) + .and_then(handlers::feeds::jsonfeed); + let atom = warp::path("blog.atom") + .and(with_state(state.clone())) + .and_then(handlers::feeds::atom); + let rss = warp::path("blog.rss") + .and(with_state(state.clone())) + .and_then(handlers::feeds::rss); + let sitemap = warp::path("sitemap.xml") + .and(with_state(state.clone())) + .and_then(handlers::feeds::sitemap); + + let go_vanity_jsonfeed = warp::path("jsonfeed") + .and(warp::any().map(move || "christine.website/jsonfeed")) + .and(warp::any().map(move || "https://tulpa.dev/Xe/jsonfeed")) + .and_then(go_vanity::gitea); + + let metrics_endpoint = warp::path("metrics").and(warp::path::end()).map(move || { + let encoder = TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = vec![]; + encoder.encode(&metric_families, &mut buffer).unwrap(); + Response::builder() + .status(200) + .header(CONTENT_TYPE, encoder.format_type()) + .body(Body::from(buffer)) + .unwrap() + }); + + let site = index + .or(contact.or(feeds).or(resume.or(signalboost)).or(patrons)) + .or(blog_index.or(series.or(series_view).or(post_view))) + .or(gallery_index.or(gallery_post_view)) + .or(talk_index.or(talk_post_view)) + .or(jsonfeed.or(atom).or(rss.or(sitemap))) + .or(files.or(css).or(favicon).or(sw.or(robots))) + .or(healthcheck.or(metrics_endpoint).or(go_vanity_jsonfeed)) + .map(|reply| { + warp::reply::with_header( + reply, + "X-Hacker", + "If you are reading this, check out /signalboost to find people for your team", + ) + }) + .map(|reply| warp::reply::with_header(reply, "X-Clacks-Overhead", "GNU Ashlynn")) + .with(warp::log(APPLICATION_NAME)) + .recover(handlers::rejection); + + warp::serve(site).run(([0, 0, 0, 0], 3030)).await; + + Ok(()) +} + +include!(concat!(env!("OUT_DIR"), "/templates.rs")); diff --git a/src/post/frontmatter.rs b/src/post/frontmatter.rs new file mode 100644 index 0000000..1cc8032 --- /dev/null +++ b/src/post/frontmatter.rs @@ -0,0 +1,114 @@ +/// This code was borrowed from @fasterthanlime. + +use anyhow::{Result}; +use serde::{Serialize, Deserialize}; + +#[derive(Eq, PartialEq, Deserialize, Default, Debug, Serialize, Clone)] +pub struct Data { + pub title: String, + pub date: String, + pub series: Option, + pub tags: Option>, + pub slides_link: Option, + pub image: Option, + pub thumb: Option, + pub show: Option, +} + +enum State { + SearchForStart, + ReadingMarker { count: usize, end: bool }, + ReadingFrontMatter { buf: String, line_start: bool }, + SkipNewline { end: bool }, +} + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("EOF while parsing frontmatter")] + EOF, + #[error("Error parsing yaml: {0:?}")] + Yaml(#[from] serde_yaml::Error), +} + +impl Data { + pub fn parse(input: &str) -> Result<(Data, usize)> { + let mut state = State::SearchForStart; + + let mut payload = None; + let offset; + + let mut chars = input.char_indices(); + 'parse: loop { + let (idx, ch) = match chars.next() { + Some(x) => x, + None => return Err(Error::EOF)?, + }; + match &mut state { + State::SearchForStart => match ch { + '-' => { + state = State::ReadingMarker { + count: 1, + end: false, + }; + } + '\n' | '\t' | ' ' => { + // ignore whitespace + } + _ => { + panic!("Start of frontmatter not found"); + } + }, + State::ReadingMarker { count, end } => match ch { + '-' => { + *count += 1; + if *count == 3 { + state = State::SkipNewline { end: *end }; + } + } + _ => { + panic!("Malformed frontmatter marker"); + } + }, + State::SkipNewline { end } => match ch { + '\n' => { + if *end { + offset = idx + 1; + break 'parse; + } else { + state = State::ReadingFrontMatter { + buf: String::new(), + line_start: true, + }; + } + } + _ => panic!("Expected newline, got {:?}",), + }, + State::ReadingFrontMatter { buf, line_start } => match ch { + '-' if *line_start => { + let mut state_temp = State::ReadingMarker { + count: 1, + end: true, + }; + std::mem::swap(&mut state, &mut state_temp); + if let State::ReadingFrontMatter { buf, .. } = state_temp { + payload = Some(buf); + } else { + unreachable!(); + } + } + ch => { + buf.push(ch); + *line_start = ch == '\n'; + } + }, + } + } + + // unwrap justification: option set in state machine, Rust can't statically analyze it + let payload = payload.unwrap(); + + let fm: Self = serde_yaml::from_str(&payload)?; + + Ok((fm, offset)) + } +} diff --git a/src/post/mod.rs b/src/post/mod.rs new file mode 100644 index 0000000..ca1f3b6 --- /dev/null +++ b/src/post/mod.rs @@ -0,0 +1,178 @@ +use anyhow::{anyhow, Result}; +use atom_syndication as atom; +use chrono::prelude::*; +use glob::glob; +use std::{cmp::Ordering, fs}; + +pub mod frontmatter; + +#[derive(Eq, PartialEq, Debug, Clone)] +pub struct Post { + pub front_matter: frontmatter::Data, + pub link: String, + pub body: String, + pub body_html: String, + pub date: DateTime, +} + +impl Into for Post { + fn into(self) -> jsonfeed::Item { + let mut result = jsonfeed::Item::builder() + .title(self.front_matter.title) + .content_html(self.body_html) + .content_text(self.body) + .id(format!("https://christine.website/{}", self.link)) + .url(format!("https://christine.website/{}", self.link)) + .date_published(self.date.to_rfc3339()) + .author( + jsonfeed::Author::new() + .name("Christine Dodrill") + .url("https://christine.website") + .avatar("https://christine.website/static/img/avatar.png"), + ); + + let mut tags: Vec = vec![]; + + if let Some(series) = self.front_matter.series { + tags.push(series); + } + + if let Some(mut meta_tags) = self.front_matter.tags { + tags.append(&mut meta_tags); + } + + if tags.len() != 0 { + result = result.tags(tags); + } + + if let Some(image_url) = self.front_matter.image { + result = result.image(image_url); + } + + result.build().unwrap() + } +} + +impl Into for Post { + fn into(self) -> atom::Entry { + let mut content = atom::ContentBuilder::default(); + + content.src(format!("https://christine.website/{}", self.link)); + content.content_type(Some("text/html;charset=utf-8".into())); + content.value(Some(xml::escape::escape_str_pcdata(&self.body_html).into())); + + let content = content.build().unwrap(); + + let mut result = atom::EntryBuilder::default(); + result.id(format!("https://christine.website/{}", self.link)); + result.contributors({ + let mut me = atom::Person::default(); + + me.set_name("Christine Dodrill"); + me.set_email("me@christine.website".to_string()); + me.set_uri("https://christine.website".to_string()); + + vec![me] + }); + result.title(self.front_matter.title); + let mut link = atom::Link::default(); + link.href = format!("https://christine.website/{}", self.link); + result.links(vec![link]); + result.content(content); + result.published(self.date); + + result.build().unwrap() + } +} + +impl Into for Post { + fn into(self) -> rss::Item { + let mut guid = rss::Guid::default(); + guid.set_value(format!("https://christine.website/{}", self.link)); + let mut result = rss::ItemBuilder::default(); + result.title(Some(self.front_matter.title)); + result.link(format!("https://christine.website/{}", self.link)); + result.guid(guid); + result.author(Some("me@christine.website (Christine Dodrill)".to_string())); + result.content(self.body_html); + result.pub_date(self.date.to_rfc2822()); + + result.build().unwrap() + } +} + +impl Ord for Post { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(&other).unwrap() + } +} + +impl PartialOrd for Post { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.date.cmp(&other.date)) + } +} + +impl Post { + pub fn detri(&self) -> String { + self.date.format("M%m %d %Y").to_string() + } +} + +pub fn load(dir: &str) -> Result> { + let mut result: Vec = vec![]; + + for path in glob(&format!("{}/*.markdown", dir))?.filter_map(Result::ok) { + let body = fs::read_to_string(path.clone())?; + let (fm, content_offset) = frontmatter::Data::parse(body.clone().as_str())?; + let markup = &body[content_offset..]; + let date = NaiveDate::parse_from_str(&fm.clone().date, "%Y-%m-%d")?; + + result.push(Post { + front_matter: fm, + link: format!("{}/{}", dir, path.file_stem().unwrap().to_str().unwrap()), + body: markup.to_string(), + body_html: crate::app::markdown(&markup), + date: { + DateTime::::from_utc( + NaiveDateTime::new(date, NaiveTime::from_hms(0, 0, 0)), + Utc, + ) + .with_timezone(&Utc) + .into() + }, + }) + } + + if result.len() == 0 { + Err(anyhow!("no posts loaded")) + } else { + result.sort(); + result.reverse(); + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + + #[test] + fn blog() -> Result<()> { + load("blog")?; + Ok(()) + } + + #[test] + fn gallery() -> Result<()> { + load("gallery")?; + Ok(()) + } + + #[test] + fn talks() -> Result<()> { + load("talks")?; + Ok(()) + } +} diff --git a/src/signalboost.rs b/src/signalboost.rs new file mode 100644 index 0000000..079990b --- /dev/null +++ b/src/signalboost.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Person { + pub name: String, + pub tags: Vec, + + #[serde(rename = "gitLink")] + pub git_link: String, + + pub twitter: String, +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + #[test] + fn load() -> Result<()> { + let _people: Vec = serde_dhall::from_file("./signalboost.dhall").parse()?; + + Ok(()) + } +} -- cgit v1.2.3