aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristine Dodrill <me@christine.website>2020-07-16 15:32:30 -0400
committerGitHub <noreply@github.com>2020-07-16 15:32:30 -0400
commit385d25c9f96c0acd5d932488e3bd0ed36ceb4dd7 (patch)
treeaf789f7250519b23038a7e5ea0ae7f4f4c1ffdfc /src
parent449e934246c82d90dd0aac2644d67f928befeeb4 (diff)
downloadxesite-385d25c9f96c0acd5d932488e3bd0ed36ceb4dd7.tar.xz
xesite-385d25c9f96c0acd5d932488e3bd0ed36ceb4dd7.zip
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
Diffstat (limited to 'src')
-rw-r--r--src/app.rs191
-rw-r--r--src/build.rs11
-rw-r--r--src/handlers/blog.rs77
-rw-r--r--src/handlers/feeds.rs73
-rw-r--r--src/handlers/gallery.rs40
-rw-r--r--src/handlers/mod.rs145
-rw-r--r--src/handlers/talks.rs40
-rw-r--r--src/main.rs154
-rw-r--r--src/post/frontmatter.rs114
-rw-r--r--src/post/mod.rs178
-rw-r--r--src/signalboost.rs23
11 files changed, 1046 insertions, 0 deletions
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<String>,
+ signalboost: Vec<Person>,
+ 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<Option<patreon::Users>> {
+ 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<Person>,
+ pub resume: String,
+ pub blog: Vec<Post>,
+ pub gallery: Vec<Post>,
+ pub talks: Vec<Post>,
+ pub everything: Vec<Post>,
+ pub jf: jsonfeed::Feed,
+ pub rf: rss::Channel,
+ pub af: atom::Feed,
+ pub sitemap: Vec<u8>,
+ pub patrons: Option<patreon::Users>,
+}
+
+pub async fn init(cfg: PathBuf) -> Result<State> {
+ 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<Post> = 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<rss::Item> = vec![];
+ let mut ai: Vec<atom::Entry> = 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<u8> = 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<State>) -> Result<impl Reply, Rejection> {
+ let state = state.clone();
+ Response::builder().html(|o| templates::blogindex_html(o, state.blog.clone()))
+}
+
+pub async fn series(state: Arc<State>) -> Result<impl Reply, Rejection> {
+ let state = state.clone();
+ let mut series: Vec<String> = 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<State>) -> Result<impl Reply, Rejection> {
+ let state = state.clone();
+ let mut posts: Vec<Post> = 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<State>) -> Result<impl Reply, Rejection> {
+ let mut want: Option<Post> = 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<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ let state = state.clone();
+ Response::builder().html(|o| templates::galleryindex_html(o, state.gallery.clone()))
+}
+
+pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
+ let mut want: Option<Post> = 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<impl Reply, Rejection> {
+ HIT_COUNTER.with_label_values(&["index"]).inc();
+ Response::builder().html(|o| templates::index_html(o))
+}
+
+pub async fn contact() -> Result<impl Reply, Rejection> {
+ HIT_COUNTER.with_label_values(&["contact"]).inc();
+ Response::builder().html(|o| templates::contact_html(o))
+}
+
+pub async fn feeds() -> Result<impl Reply, Rejection> {
+ HIT_COUNTER.with_label_values(&["feeds"]).inc();
+ Response::builder().html(|o| templates::feeds_html(o))
+}
+
+pub async fn resume(state: Arc<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ 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<impl Reply, Rejection> {
+ 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<PostNotFound> 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<SeriesNotFound> 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<impl Reply, Infallible> {
+ 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<State>) -> Result<impl Reply, Rejection> {
+ let state = state.clone();
+ Response::builder().html(|o| templates::talkindex_html(o, state.talks.clone()))
+}
+
+pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
+ let mut want: Option<Post> = 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<State>,
+) -> impl Filter<Extract = (Arc<State>,), 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<String>,
+ pub tags: Option<Vec<String>>,
+ pub slides_link: Option<String>,
+ pub image: Option<String>,
+ pub thumb: Option<String>,
+ pub show: Option<bool>,
+}
+
+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<FixedOffset>,
+}
+
+impl Into<jsonfeed::Item> 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()