Rewrite the site routing with Axum (#441)

* broken state

Signed-off-by: Xe Iaso <me@christine.website>

* fix???

Signed-off-by: Xe Iaso <me@christine.website>

* Port everything else to axum

Signed-off-by: Xe <me@christine.website>

* headers

Signed-off-by: Xe Iaso <me@christine.website>

* site update post

Signed-off-by: Christine Dodrill <me@christine.website>

* fix headers

Signed-off-by: Xe Iaso <me@christine.website>

* remove warp example

Signed-off-by: Xe Iaso <me@christine.website>

* 80c wrap

Signed-off-by: Xe Iaso <me@christine.website>

* bump version

Signed-off-by: Xe Iaso <me@christine.website>
This commit is contained in:
Cadey Ratio 2022-03-21 20:14:14 -04:00 committed by GitHub
parent f45ca40ae1
commit 8b747c1c40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1272 additions and 922 deletions

1135
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "xesite"
version = "2.3.0"
version = "2.4.0"
authors = ["Xe Iaso <me@christine.website>"]
edition = "2018"
build = "src/build.rs"
@ -9,13 +9,19 @@ repository = "https://github.com/Xe/site"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.4"
axum-macros = "0.1"
axum-extra = "0.1"
color-eyre = "0.6"
chrono = "0.4"
comrak = "0.12.1"
derive_more = "0.99"
envy = "0.4"
estimated_read_time = "1"
futures = "0.3"
glob = "0.3"
http = "0.2"
http-body = "0.4"
hyper = "0.14"
kankyo = "0.3"
lazy_static = "1.4"
@ -34,24 +40,30 @@ tokio-stream = { version = "0.1", features = ["net"] }
tracing = "0.1"
tracing-futures = "0.2"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
warp = "0.3"
xml-rs = "0.8"
url = "2"
uuid = { version = "0.8", features = ["serde", "v4"] }
# workspace dependencies
cfcache = { path = "./lib/cfcache" }
go_vanity = { path = "./lib/go_vanity" }
jsonfeed = { path = "./lib/jsonfeed" }
mi = { path = "./lib/mi" }
patreon = { path = "./lib/patreon" }
[dependencies.tower]
version = "0.4"
features = [ "full" ]
[dependencies.tower-http]
version = "0.2"
features = [ "full" ]
# os-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
sdnotify = { version = "0.2", default-features = false }
[build-dependencies]
ructe = { version = "0.13", features = ["warp02"] }
ructe = { version = "0.13", features = [ "mime03" ] }
[dev-dependencies]
pfacts = "0"

View File

@ -0,0 +1,81 @@
---
title: "Site Update: Axum"
date: 2022-03-21
---
I have made a bunch of huge changes to my website that hopefully you won't
notice unless you read this post that points them out to you. I have redone how
the website's URL routing works to use
[axum](https://tokio.rs/blog/2021-07-announcing-axum) instead of
[warp](https://docs.rs/warp/latest/warp/).
I chose warp fairly arbitrarily when I was getting into the swing of Rust. This
choice turned out to be a bit of a mistake. Don't get me wrong, warp is a
fantastic framework, but overall I've not been happy with how it impacts compile
times. Warp works by pushing a lot of the complexities with HTTP routing into
the type system. This can lead to undebuggable inscruitable types that make it
really hard to understand what is wrong. Here is the autogenerated type for the
`/blog/:name` route:
```
warp::filter::and::And<warp::filter::and::And<impl warp::filter::FilterBase<Extract = (), Error = Infallible> + warp::Filter + std::marker::Copy, Exact<warp::path::internal::Opaque<main::{closure#0}::__StaticPath>>>, warp::filter::and_then::AndThen<warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<impl warp::filter::FilterBase<Extract = (), Error = Infallible> + warp::Filter + std::marker::Copy, impl warp::filter::FilterBase<Extract = (std::string::String,), Error = Rejection> + warp::Filter + std::marker::Copy>, impl warp::filter::FilterBase<Extract = (), Error = Rejection> + warp::Filter + std::marker::Copy>, impl warp::filter::FilterBase<Extract = (Arc<app::State>,), Error = Infallible> + warp::Filter + Clone>, impl warp::filter::FilterBase<Extract = (), Error = Rejection> + warp::Filter + std::marker::Copy>, fn(std::string::String, Arc<app::State>) -> impl warp::Future<Output = Result<Opaque(DefId(0:1249 ~ xesite[3495]::handlers::blog::post_view::{opaque#0}::{opaque#0}), []), Rejection>> {blog::post_view}>>
```
[What the heck is that? Is that a binary tree?](conversation://Numa/delet)
[Yep. It's insufferable to try and debug too.](conversation://Cadey/coffee)
Yeah, it's really hard to understand what's going on in error messages because
of this. This also means that the routes are put into a binary tree in the type
system, which means if your tree is unbalanced then you get slower compile times
and a slight hit at runtime as the framework traverses your binary tree to
figure out what to do. This has also made it difficult for me to add features
such as [historical views of my RSS feed](https://github.com/Xe/site/issues/419)
or other things I want to add like the April Fools day feature I've been working
on.
When I went out framework shopping, I tried a few things and got reccomendations
from a trusted friend before I finally settled on axum as the heart of this
website. Axum has a few major advantages that bbrought me "in the door":
- It's maintained by the tokio team
- It leverages the type system of Rust to make writing handlers easier
- It uses extractors (think lenses) to help you pick out the subset of data you
need, not blindly giving you everything and hoping you figure it out
- It has sub-routers which can have different middleware stacks than the main
one (useful for things like API authentication)
And it has these disadvantages:
- Writing middleware is kinda weird (though this may be because I'm not used to
working with tower), but easy once you get the general flow of things
- I can't find a way to have the template data get continuously piped to client
connections instead of rendering it to a buffer and then writing that buffer
to the client
- It doesn't have the biggest mindshare and one of the best ways to get unstuck
at the time of writing this article is to ask on their Discord server
Overall, I've been happy with the experience of porting over my site to using
Axum. I did [a stream on Twitch](https://www.twitch.tv/videos/1429533858) where
I ported it all over if you want to watch the process and hear my thought
processes as I was figuring things out.
As users, nothing should have changed about this site. However I'm almost
certain that I did forget to port _something_ over, so if I missed something you
rely on, [get in contact](/contact). I have not gotten the Patreon API
interoperability code fixed yet, so that is the next major issue. I am going to
figure out how refresh tokens work the hard way and make the patrons page
auto-updating instead of having to [manually get a new API key every
month](https://github.com/Xe/site/blob/main/docs/patron-page.org). I am also
looking into having that patrons page be updated by a cronjob that emits json to
the disk and have my site load from that instead of just hoping that the patreon
API credentials are up to date. We'll see how that goes, but you can track that
[here](https://github.com/Xe/site/issues/442). I will likely do a livestream for
this.
I have also contacted a copyeditor for my blog. I am so happy with the results
so far. [My Devops post](/blog/social-quandry-devops-2022-03-17) was the first
thing that the editor reviewed and they absolutely tore my first draft in half
and helped me put the parts back together into something more palateable. I am
beyond satisfied with this and will continue to use this editor in the future. I
wish I had gotten a copyeditor sooner.

View File

@ -1,14 +0,0 @@
use warp::Filter;
#[tokio::main]
async fn main() {
let hello = warp::path!("hello" / String)
.map(|name| format!("Hello, {}!", name));
let health = warp::path!(".within" / "health")
.map(|| "OK");
let routes = hello.or(health);
warp::serve(routes)
.run(([0, 0, 0, 0], 3030))
.await;
}

View File

@ -1,15 +0,0 @@
[package]
name = "go_vanity"
version = "0.2.0"
authors = ["Xe Iaso <me@christine.website>"]
edition = "2018"
build = "src/build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
mime = "0.3"
warp = "0.3"
[build-dependencies]
ructe = { version = "0.13", features = ["warp02"] }

View File

@ -1,5 +0,0 @@
use ructe::{Result, Ructe};
fn main() -> Result<()> {
Ructe::from_env()?.compile_templates("templates")
}

View File

@ -1,12 +0,0 @@
use crate::templates::RenderRucte;
use warp::{http::Response, Rejection, Reply};
include!(concat!(env!("OUT_DIR"), "/templates.rs"));
pub async fn gitea(pkg_name: &str, git_repo: &str, branch: &str) -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::gitea_html(o, pkg_name, git_repo, branch))
}
pub async fn github(pkg_name: &str, git_repo: &str, branch: &str) -> Result<impl Reply, Rejection> {
Response::builder().html(|o| templates::github_html(o, pkg_name, git_repo, branch))
}

View File

@ -1,14 +0,0 @@
@(pkg_name: &str, git_repo: &str, branch: &str)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="go-import" content="@pkg_name git @git_repo">
<meta name="go-source" content="@pkg_name @git_repo @git_repo/tree/@branch@{/dir@} @git_repo/blob/@branch@{/dir@}/@{file@}#L@{line@}">
<meta http-equiv="refresh" content="0; url=https://godoc.org/@pkg_name">
</head>
<body>
Please see <a href="https://godoc.org/@pkg_name">here</a> for documentation on this package.
</body>
</html>

View File

@ -1,6 +1,6 @@
use color_eyre::eyre::Result;
use reqwest::header;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tracing::instrument;
const USER_AGENT_BASE: &str = concat!(
@ -58,7 +58,7 @@ impl Client {
}
}
#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Serialize)]
pub struct WebMention {
pub source: String,
pub title: Option<String>,

94
src/domainsocket.rs Normal file
View File

@ -0,0 +1,94 @@
use axum::extract::connect_info;
use futures::ready;
use hyper::{
client::connect::{Connected, Connection},
server::accept::Accept,
};
use std::{
io,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::{
io::{AsyncRead, AsyncWrite},
net::{unix::UCred, UnixListener, UnixStream},
};
use tower::BoxError;
pub struct ServerAccept {
pub uds: UnixListener,
}
impl Accept for ServerAccept {
type Conn = UnixStream;
type Error = BoxError;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
let (stream, _addr) = ready!(self.uds.poll_accept(cx))?;
Poll::Ready(Some(Ok(stream)))
}
}
pub struct ClientConnection {
pub stream: UnixStream,
}
impl AsyncWrite for ClientConnection {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
Pin::new(&mut self.stream).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.stream).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
impl AsyncRead for ClientConnection {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.stream).poll_read(cx, buf)
}
}
impl Connection for ClientConnection {
fn connected(&self) -> Connected {
Connected::new()
}
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct UdsConnectInfo {
pub peer_addr: Arc<tokio::net::unix::SocketAddr>,
pub peer_cred: UCred,
}
impl connect_info::Connected<&UnixStream> for UdsConnectInfo {
fn connect_info(target: &UnixStream) -> Self {
let peer_addr = target.peer_addr().unwrap();
let peer_cred = target.peer_cred().unwrap();
Self {
peer_addr: Arc::new(peer_addr),
peer_cred,
}
}
}

View File

@ -1,14 +1,13 @@
use super::{PostNotFound, SeriesNotFound, LAST_MODIFIED};
use crate::{
app::State,
post::Post,
templates::{self, Html, RenderRucte},
use super::Result;
use crate::{app::State, post::Post, templates};
use axum::{
extract::{Extension, Path},
response::Html,
};
use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::sync::Arc;
use tracing::{error, instrument};
use warp::{http::Response, Rejection, Reply};
lazy_static! {
static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!(
@ -19,17 +18,18 @@ lazy_static! {
}
#[instrument(skip(state))]
pub async fn index(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn index(Extension(state): Extension<Arc<State>>) -> Result {
let state = state.clone();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::blogindex_html(o, state.blog.clone()))
let mut result: Vec<u8> = vec![];
templates::blogindex_html(&mut result, state.blog.clone())?;
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn series(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn series(Extension(state): Extension<Arc<State>>) -> Result {
let state = state.clone();
let mut series: Vec<String> = vec![];
let mut result: Vec<u8> = vec![];
for post in &state.blog {
if post.front_matter.series.is_some() {
@ -40,15 +40,18 @@ pub async fn series(state: Arc<State>) -> Result<impl Reply, Rejection> {
series.sort();
series.dedup();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::series_html(o, series))
templates::series_html(&mut result, series)?;
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn series_view(series: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn series_view(
Path(series): Path<String>,
Extension(state): Extension<Arc<State>>,
) -> Result {
let state = state.clone();
let mut posts: Vec<Post> = vec![];
let mut result: Vec<u8> = vec![];
for post in &state.blog {
if post.front_matter.series.is_none() {
@ -62,16 +65,18 @@ pub async fn series_view(series: String, state: Arc<State>) -> Result<impl Reply
if posts.len() == 0 {
error!("series not found");
Err(SeriesNotFound(series).into())
} else {
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::series_posts_html(o, series, &posts))
return Err(super::Error::SeriesNotFound(series));
}
templates::series_posts_html(&mut result, series, &posts).unwrap();
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn post_view(
Path(name): Path<String>,
Extension(state): Extension<Arc<State>>,
) -> Result {
let mut want: Option<Post> = None;
for post in &state.blog {
@ -81,15 +86,15 @@ pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Re
}
match want {
None => Err(PostNotFound("blog".into(), name).into()),
None => Err(super::Error::PostNotFound(name)),
Some(post) => {
HIT_COUNTER
.with_label_values(&[name.clone().as_str()])
.inc();
let body = Html(post.body_html.clone());
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::blogpost_html(o, post, body))
let body = templates::Html(post.body_html.clone());
let mut result: Vec<u8> = vec![];
templates::blogpost_html(&mut result, post, body)?;
Ok(Html(result))
}
}
}

View File

@ -1,10 +1,14 @@
use super::LAST_MODIFIED;
use crate::{app::State, post::Post, templates};
use super::{Result, LAST_MODIFIED};
use crate::{
app::State,
post::{NewPost, Post},
templates,
};
use axum::{body, extract::Extension, response::Response, Json};
use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::{io, sync::Arc};
use std::sync::Arc;
use tracing::instrument;
use warp::{http::Response, Rejection, Reply};
lazy_static! {
pub static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!(
@ -16,103 +20,56 @@ lazy_static! {
}
#[instrument(skip(state))]
pub async fn jsonfeed(state: Arc<State>, since: Option<String>) -> Result<impl Reply, Rejection> {
pub async fn jsonfeed(Extension(state): Extension<Arc<State>>) -> Json<jsonfeed::Feed> {
HIT_COUNTER.with_label_values(&["json"]).inc();
let state = state.clone();
Ok(warp::reply::json(&state.jf))
Json(state.jf.clone())
}
#[instrument(skip(state))]
pub async fn new_post(state: Arc<State>) -> Result<impl Reply, Rejection> {
#[axum_macros::debug_handler]
pub async fn new_post(Extension(state): Extension<Arc<State>>) -> Result<Json<NewPost>> {
let state = state.clone();
let everything = state.everything.clone();
let p: &Post = everything.iter().next().unwrap();
Ok(warp::reply::json(&p.new_post))
let p: Post = state.everything.iter().next().unwrap().clone();
Ok(Json(p.new_post))
}
#[derive(Debug)]
pub enum RenderError {
Build(warp::http::Error),
IO(io::Error),
}
impl warp::reject::Reject for RenderError {}
#[instrument(skip(state))]
pub async fn atom(state: Arc<State>, since: Option<String>) -> Result<impl Reply, Rejection> {
if let Some(etag) = since {
if etag == ETAG.clone() {
return Response::builder()
.status(304)
.header("Content-Type", "text/plain")
.body(
"You already have the newest version of this feed."
.to_string()
.into_bytes(),
)
.map_err(RenderError::Build)
.map_err(warp::reject::custom);
}
}
pub async fn atom(Extension(state): Extension<Arc<State>>) -> Result<Response> {
HIT_COUNTER.with_label_values(&["atom"]).inc();
let state = state.clone();
let mut buf = Vec::new();
templates::blog_atom_xml(&mut buf, state.everything.clone())
.map_err(RenderError::IO)
.map_err(warp::reject::custom)?;
Response::builder()
templates::blog_atom_xml(&mut buf, state.everything.clone())?;
Ok(Response::builder()
.status(200)
.header("Content-Type", "application/atom+xml")
.header("ETag", ETAG.clone())
.header("Last-Modified", &*LAST_MODIFIED)
.body(buf)
.map_err(RenderError::Build)
.map_err(warp::reject::custom)
.body(body::boxed(body::Full::from(buf)))?)
}
#[instrument(skip(state))]
pub async fn rss(state: Arc<State>, since: Option<String>) -> Result<impl Reply, Rejection> {
if let Some(etag) = since {
if etag == ETAG.clone() {
return Response::builder()
.status(304)
.header("Content-Type", "text/plain")
.body(
"You already have the newest version of this feed."
.to_string()
.into_bytes(),
)
.map_err(RenderError::Build)
.map_err(warp::reject::custom);
}
}
pub async fn rss(Extension(state): Extension<Arc<State>>) -> Result<Response> {
HIT_COUNTER.with_label_values(&["rss"]).inc();
let state = state.clone();
let mut buf = Vec::new();
templates::blog_rss_xml(&mut buf, state.everything.clone())
.map_err(RenderError::IO)
.map_err(warp::reject::custom)?;
Response::builder()
templates::blog_rss_xml(&mut buf, state.everything.clone())?;
Ok(Response::builder()
.status(200)
.header("Content-Type", "application/rss+xml")
.header("ETag", ETAG.clone())
.header("Last-Modified", &*LAST_MODIFIED)
.body(buf)
.map_err(RenderError::Build)
.map_err(warp::reject::custom)
.body(body::boxed(body::Full::from(buf)))?)
}
#[instrument(skip(state))]
pub async fn sitemap(state: Arc<State>) -> Result<impl Reply, Rejection> {
#[axum_macros::debug_handler]
pub async fn sitemap(Extension(state): Extension<Arc<State>>) -> Result<Response> {
HIT_COUNTER.with_label_values(&["sitemap"]).inc();
let state = state.clone();
Response::builder()
Ok(Response::builder()
.status(200)
.header("Content-Type", "application/xml")
.header("Last-Modified", &*LAST_MODIFIED)
.body(state.sitemap.clone())
.map_err(RenderError::Build)
.map_err(warp::reject::custom)
.body(body::boxed(body::Full::from(state.sitemap.clone())))?)
}

View File

@ -1,14 +1,13 @@
use super::PostNotFound;
use crate::{
app::State,
post::Post,
templates::{self, Html, RenderRucte},
use super::{Error::*, Result};
use crate::{app::State, post::Post, templates};
use axum::{
extract::{Extension, Path},
response::Html,
};
use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::sync::Arc;
use tracing::instrument;
use warp::{http::Response, Rejection, Reply};
lazy_static! {
static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!(
@ -19,13 +18,18 @@ lazy_static! {
}
#[instrument(skip(state))]
pub async fn index(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn index(Extension(state): Extension<Arc<State>>) -> Result {
let state = state.clone();
Response::builder().html(|o| templates::galleryindex_html(o, state.gallery.clone()))
let mut result: Vec<u8> = vec![];
templates::galleryindex_html(&mut result, state.gallery.clone())?;
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn post_view(
Path(name): Path<String>,
Extension(state): Extension<Arc<State>>,
) -> Result {
let mut want: Option<Post> = None;
for post in &state.gallery {
@ -35,13 +39,15 @@ pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Re
}
match want {
None => Err(PostNotFound("gallery".into(), name).into()),
None => Err(PostNotFound(name)),
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))
let body = templates::Html(post.body_html.clone());
let mut result: Vec<u8> = vec![];
templates::gallerypost_html(&mut result, post, body)?;
Ok(Html(result))
}
}
}

View File

@ -1,16 +1,20 @@
use crate::{
app::State,
templates::{self, Html, RenderRucte},
use crate::{app::State, templates};
use axum::{
body,
extract::Extension,
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use chrono::{Datelike, Timelike, Utc};
use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::{convert::Infallible, fmt, sync::Arc};
use std::sync::Arc;
use tracing::instrument;
use warp::{
http::{Response, StatusCode},
Rejection, Reply,
};
pub mod blog;
pub mod feeds;
pub mod gallery;
pub mod talks;
lazy_static! {
static ref HIT_COUNTER: IntCounterVec =
@ -32,139 +36,104 @@ lazy_static! {
}
#[instrument]
pub async fn index() -> Result<impl Reply, Rejection> {
pub async fn index() -> Result {
HIT_COUNTER.with_label_values(&["index"]).inc();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::index_html(o))
let mut result: Vec<u8> = vec![];
templates::index_html(&mut result)?;
Ok(Html(result))
}
#[instrument]
pub async fn contact() -> Result<impl Reply, Rejection> {
pub async fn contact() -> Result {
HIT_COUNTER.with_label_values(&["contact"]).inc();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::contact_html(o))
let mut result: Vec<u8> = vec![];
templates::contact_html(&mut result)?;
Ok(Html(result))
}
#[instrument]
pub async fn feeds() -> Result<impl Reply, Rejection> {
pub async fn feeds() -> Result {
HIT_COUNTER.with_label_values(&["feeds"]).inc();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::feeds_html(o))
let mut result: Vec<u8> = vec![];
templates::feeds_html(&mut result)?;
Ok(Html(result))
}
#[axum_macros::debug_handler]
#[instrument(skip(state))]
pub async fn resume(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn resume(Extension(state): Extension<Arc<State>>) -> Result {
HIT_COUNTER.with_label_values(&["resume"]).inc();
let state = state.clone();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::resume_html(o, Html(state.resume.clone())))
let mut result: Vec<u8> = vec![];
templates::resume_html(&mut result, templates::Html(state.resume.clone()))?;
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn patrons(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn patrons(Extension(state): Extension<Arc<State>>) -> Result {
HIT_COUNTER.with_label_values(&["patrons"]).inc();
let state = state.clone();
let mut result: Vec<u8> = vec![];
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()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::patrons_html(o, patrons.clone())),
None => Err(Error::NoPatrons),
Some(patrons) => {
templates::patrons_html(&mut result, patrons.clone())?;
Ok(Html(result))
}
}
}
#[axum_macros::debug_handler]
#[instrument(skip(state))]
pub async fn signalboost(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn signalboost(Extension(state): Extension<Arc<State>>) -> Result {
HIT_COUNTER.with_label_values(&["signalboost"]).inc();
let state = state.clone();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::signalboost_html(o, state.signalboost.clone()))
let mut result: Vec<u8> = vec![];
templates::signalboost_html(&mut result, state.signalboost.clone())?;
Ok(Html(result))
}
#[instrument]
pub async fn not_found() -> Result<impl Reply, Rejection> {
pub async fn not_found() -> Result {
HIT_COUNTER.with_label_values(&["not_found"]).inc();
Response::builder()
.header("Last-Modified", &*LAST_MODIFIED)
.html(|o| templates::notfound_html(o, "some path".into()))
let mut result: Vec<u8> = vec![];
templates::notfound_html(&mut result, "some path".into())?;
Ok(Html(result))
}
pub mod blog;
pub mod feeds;
pub mod gallery;
pub mod talks;
#[derive(Debug, thiserror::Error)]
struct PostNotFound(String, String);
pub enum Error {
#[error("series not found: {0}")]
SeriesNotFound(String),
impl fmt::Display for PostNotFound {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "not found: {}/{}", self.0, self.1)
}
#[error("post not found: {0}")]
PostNotFound(String),
#[error("patreon key not working, poke me to get this fixed")]
NoPatrons,
#[error("io error: {0}")]
IO(#[from] std::io::Error),
#[error("axum http error: {0}")]
AxumHTTP(#[from] axum::http::Error),
}
impl warp::reject::Reject for PostNotFound {}
pub type Result<T = Html<Vec<u8>>> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
struct SeriesNotFound(String);
impl IntoResponse for Error {
fn into_response(self) -> Response {
let mut result: Vec<u8> = vec![];
templates::error_html(&mut result, format!("{}", self)).unwrap();
impl fmt::Display for SeriesNotFound {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
let body = body::boxed(body::Full::from(result));
impl warp::reject::Reject for SeriesNotFound {}
lazy_static! {
static ref REJECTION_COUNTER: IntCounterVec = register_int_counter_vec!(
opts!("rejections", "Number of rejections by kind"),
&["kind"]
)
.unwrap();
}
#[instrument]
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,
))
.status(match self {
Error::SeriesNotFound(_) | Error::PostNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
})
.body(body)
.unwrap()
}
}

View File

@ -1,14 +1,13 @@
use super::PostNotFound;
use crate::{
app::State,
post::Post,
templates::{self, Html, RenderRucte},
use super::{Error::*, Result};
use crate::{app::State, post::Post, templates};
use axum::{
extract::{Extension, Path},
response::Html,
};
use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::sync::Arc;
use tracing::instrument;
use warp::{http::Response, Rejection, Reply};
lazy_static! {
static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!(
@ -19,13 +18,18 @@ lazy_static! {
}
#[instrument(skip(state))]
pub async fn index(state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn index(Extension(state): Extension<Arc<State>>) -> Result {
let state = state.clone();
Response::builder().html(|o| templates::talkindex_html(o, state.talks.clone()))
let mut result: Vec<u8> = vec![];
templates::talkindex_html(&mut result, state.talks.clone())?;
Ok(Html(result))
}
#[instrument(skip(state))]
pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Rejection> {
pub async fn post_view(
Path(name): Path<String>,
Extension(state): Extension<Arc<State>>,
) -> Result {
let mut want: Option<Post> = None;
for post in &state.talks {
@ -35,13 +39,15 @@ pub async fn post_view(name: String, state: Arc<State>) -> Result<impl Reply, Re
}
match want {
None => Err(PostNotFound("talks".into(), name).into()),
None => Err(PostNotFound(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))
let body = templates::Html(post.body_html.clone());
let mut result: Vec<u8> = vec![];
templates::talkpost_html(&mut result, post, body)?;
Ok(Html(result))
}
}
}

View File

@ -1,29 +1,64 @@
#[macro_use]
extern crate tracing;
use axum::{
body,
extract::Extension,
http::header::{self, HeaderValue, CACHE_CONTROL, CONTENT_TYPE},
response::{Html, Response},
routing::get,
Router,
};
use color_eyre::eyre::Result;
use hyper::{header::CONTENT_TYPE, Body, Response};
use hyper::StatusCode;
use prometheus::{Encoder, TextEncoder};
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::Arc;
use std::{
env,
net::{IpAddr, SocketAddr},
str::FromStr,
sync::Arc,
};
use tokio::net::UnixListener;
use tokio_stream::wrappers::UnixListenerStream;
use warp::{path, Filter};
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
trace::TraceLayer,
};
pub mod app;
pub mod handlers;
pub mod post;
pub mod signalboost;
use app::State;
mod domainsocket;
use domainsocket::*;
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())
async fn healthcheck() -> &'static str {
"OK"
}
fn cache_header(_: &Response) -> Option<header::HeaderValue> {
Some(header::HeaderValue::from_static(
"public, max-age=3600, stale-if-error=60",
))
}
fn webmention_header(_: &Response) -> Option<HeaderValue> {
Some(header::HeaderValue::from_static(
r#"<https://mi.within.website/api/webmention/accept>; rel="webmention""#,
))
}
fn clacks_header(_: &Response) -> Option<HeaderValue> {
Some(HeaderValue::from_static("Ashlynn"))
}
fn hacker_header(_: &Response) -> Option<HeaderValue> {
Some(header::HeaderValue::from_static(
"If you are reading this, check out /signalboost to find people for your team",
))
}
#[tokio::main]
@ -35,7 +70,7 @@ async fn main() -> Result<()> {
let state = Arc::new(
app::init(
std::env::var("CONFIG_FNAME")
env::var("CONFIG_FNAME")
.unwrap_or("./config.dhall".into())
.as_str()
.into(),
@ -43,178 +78,129 @@ async fn main() -> Result<()> {
.await?,
);
let healthcheck = warp::get().and(warp::path(".within").and(warp::path("health")).map(|| "OK"));
let new_post = warp::path!(".within" / "website.within.xesite" / "new_post")
.and(with_state(state.clone()))
.and_then(handlers::feeds::new_post);
let middleware = tower::ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(Extension(state.clone()))
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
cache_header,
))
.layer(SetResponseHeaderLayer::appending(
header::LINK,
webmention_header,
))
.layer(SetResponseHeaderLayer::appending(
header::HeaderName::from_static("x-clacks-overhead"),
clacks_header,
))
.layer(SetResponseHeaderLayer::overriding(
header::HeaderName::from_static("x-hacker"),
hacker_header,
));
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"))
.map(|reply| {
warp::reply::with_header(
reply,
"Cache-Control",
"public, max-age=86400, stale-if-error=60",
)
});
let css = warp::path("css").and(warp::fs::dir("./css")).map(|reply| {
warp::reply::with_header(
reply,
"Cache-Control",
"public, max-age=86400, stale-if-error=60",
let app = Router::new()
// meta
.route("/.within/health", get(healthcheck))
.route(
"/.within/website.within.xesite/new_post",
get(handlers::feeds::new_post),
)
});
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(warp::header::optional("if-none-match"))
.and_then(handlers::feeds::jsonfeed);
let atom = warp::path("blog.atom")
.and(with_state(state.clone()))
.and(warp::header::optional("if-none-match"))
.and_then(handlers::feeds::atom);
let rss = warp::path("blog.rss")
.and(with_state(state.clone()))
.and(warp::header::optional("if-none-match"))
.and_then(handlers::feeds::rss);
let sitemap = warp::path("sitemap.xml")
.and(with_state(state.clone()))
.and_then(handlers::feeds::sitemap);
let asset_links = warp::path!(".well-known" / "assetlinks.json")
.and(warp::fs::file("./static/assetlinks.json"));
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(warp::any().map(move || "master"))
.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 static_pages = index
.or(feeds.or(asset_links))
.or(resume.or(signalboost))
.or(patrons)
.or(jsonfeed.or(atom.or(sitemap)).or(rss))
.or(favicon.or(robots).or(sw))
.or(contact.or(new_post))
.map(|reply| {
warp::reply::with_header(
reply,
"Cache-Control",
"public, max-age=86400, stale-if-error=60",
)
});
let dynamic_pages = blog_index
.or(series.or(series_view).or(post_view))
.or(gallery_index.or(gallery_post_view))
.or(talk_index.or(talk_post_view))
.map(|reply| {
warp::reply::with_header(
reply,
"Cache-Control",
"public, max-age=600, stale-if-error=60",
)
});
let site = static_pages
.or(dynamic_pages)
.or(healthcheck.or(metrics_endpoint).or(go_vanity_jsonfeed))
.or(files.or(css))
.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"))
.map(|reply| {
warp::reply::with_header(
reply,
"Link",
format!(
r#"<{}>; rel="webmention""#,
std::env::var("WEBMENTION_URL")
.unwrap_or("https://mi.within.website/api/webmention/accept".to_string())
),
)
})
.with(warp::log(APPLICATION_NAME))
.recover(handlers::rejection);
.route("/jsonfeed", get(go_vanity))
.route("/metrics", get(metrics))
.route(
"/sw.js",
axum::routing::get_service(ServeFile::new("./static/js/sw.js")).handle_error(
|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
},
),
)
.route(
"/.well-known/assetlinks.json",
axum::routing::get_service(ServeFile::new("./static/assetlinks.json")).handle_error(
|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
},
),
)
.route(
"/robots.txt",
axum::routing::get_service(ServeFile::new("./static/robots.txt")).handle_error(
|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
},
),
)
.route(
"/favicon.ico",
axum::routing::get_service(ServeFile::new("./static/favicon/favicon.ico"))
.handle_error(|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
}),
)
// static pages
.route("/", get(handlers::index))
.route("/contact", get(handlers::contact))
.route("/feeds", get(handlers::feeds))
.route("/resume", get(handlers::resume))
.route("/patrons", get(handlers::patrons))
.route("/signalboost", get(handlers::signalboost))
// feeds
.route("/blog.json", get(handlers::feeds::jsonfeed))
.route("/blog.atom", get(handlers::feeds::atom))
.route("/blog.rss", get(handlers::feeds::rss))
// blog
.route("/blog", get(handlers::blog::index))
.route("/blog/", get(handlers::blog::index))
.route("/blog/:name", get(handlers::blog::post_view))
.route("/blog/series", get(handlers::blog::series))
.route("/blog/series/:series", get(handlers::blog::series_view))
// gallery
.route("/gallery", get(handlers::gallery::index))
.route("/gallery/", get(handlers::gallery::index))
.route("/gallery/:name", get(handlers::gallery::post_view))
// talks
.route("/talks", get(handlers::talks::index))
.route("/talks/", get(handlers::talks::index))
.route("/talks/:name", get(handlers::talks::post_view))
// junk google wants
.route("/sitemap.xml", get(handlers::feeds::sitemap))
// static files
.nest(
"/css",
axum::routing::get_service(ServeDir::new("./css")).handle_error(
|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
},
),
)
.nest(
"/static",
axum::routing::get_service(ServeDir::new("./static")).handle_error(
|err: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("unhandled internal server error: {}", err),
)
},
),
)
.layer(middleware);
#[cfg(target_os = "linux")]
{
@ -241,30 +227,51 @@ async fn main() -> Result<()> {
}
}
let server = warp::serve(site);
match std::env::var("SOCKPATH") {
Ok(sockpath) => {
let _ = std::fs::remove_file(&sockpath);
let listener = UnixListener::bind(sockpath)?;
let incoming = UnixListenerStream::new(listener);
server.run_incoming(incoming).await;
Ok(())
let uds = UnixListener::bind(&sockpath)?;
axum::Server::builder(ServerAccept { uds })
.serve(app.into_make_service_with_connect_info::<UdsConnectInfo, _>())
.await?;
}
Err(_) => {
server
.run((
IpAddr::from_str(&std::env::var("HOST").unwrap_or("::".into()))?,
std::env::var("PORT")
.unwrap_or("3030".into())
.parse::<u16>()?,
))
.await;
Ok(())
let addr: SocketAddr = (
IpAddr::from_str(&env::var("HOST").unwrap_or("::".into()))?,
env::var("PORT").unwrap_or("3030".into()).parse::<u16>()?,
)
.into();
info!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
}
}
Ok(())
}
async fn metrics() -> Response {
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::boxed(body::Full::from(buffer)))
.unwrap()
}
async fn go_vanity() -> Html<Vec<u8>> {
let mut buffer: Vec<u8> = vec![];
templates::gitea_html(
&mut buffer,
"christine.website/jsonfeed",
"https://christine.website/metrics",
"master",
)
.unwrap();
Html(buffer)
}
include!(concat!(env!("OUT_DIR"), "/templates.rs"));

View File

@ -1,13 +1,13 @@
use chrono::prelude::*;
use color_eyre::eyre::{eyre, Result, WrapErr};
use glob::glob;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{borrow::Borrow, cmp::Ordering, path::PathBuf};
use tokio::fs;
pub mod frontmatter;
#[derive(Eq, PartialEq, Debug, Clone)]
#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
pub struct Post {
pub front_matter: frontmatter::Data,
pub link: String,
@ -19,7 +19,7 @@ pub struct Post {
}
/// Used with the Android app to show information in a widget.
#[derive(Eq, PartialEq, Debug, Clone, Serialize)]
#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
pub struct NewPost {
pub title: String,
pub summary: String,