1
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
Adrian Hedqvist 7e2ebc4efb cargo fmt 2023-07-29 12:12:46 +02:00
Adrian Hedqvist eea0cc764d cargo clippy -- -W clippy::pedantic 2023-07-29 12:12:18 +02:00
Adrian Hedqvist 28a2b3ca43 i don't know 2023-07-29 11:51:04 +02:00
15 changed files with 466 additions and 311 deletions

473
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ edition = "2021"
[dependencies]
axum = { version = "0.6.12", features = ["http2"] }
cached = "0.42.0"
cached = "0.44.0"
chrono = { version = "0.4.24", features = ["serde"] }
color-eyre = "0.6.1"
glob = "0.3.0"
@ -27,5 +27,5 @@ toml = "0.7.3"
tower = { version = "0.4.12", features = ["full"] }
tower-http = { version = "0.4.0", features = ["full"] }
tracing = "0.1.35"
tracing-opentelemetry = "0.18.0"
tracing-opentelemetry = "0.19.0"
tracing-subscriber = { version = "0.3.11", features = ["fmt", "env-filter", "json", "tracing-log"] }

View file

@ -62,3 +62,15 @@ pub fn render_atom_tag_feed(tag: &Tag, state: &AppState) -> Result<String> {
Ok(state.tera.render("atom.xml", &ctx)?)
}
struct JsonFeed<'a> {
version: &'a str,
title: &'a str,
home_page_url: &'a str,
feed_url: &'a str,
items: Vec<JsonFeedItem<'a>>,
}
struct JsonFeedItem<'a> {
id: &'a str,
}

View file

@ -6,12 +6,19 @@ use axum::{
routing::get,
Router,
};
use hyper::{header::CONTENT_TYPE, Request, StatusCode};
use chrono::{DateTime, FixedOffset};
use hyper::{
header::{self, CONTENT_TYPE},
HeaderMap, Request, StatusCode,
};
use lazy_static::lazy_static;
use prometheus::{opts, Encoder, IntCounterVec, TextEncoder};
use std::sync::Arc;
use tower_http::services::ServeFile;
use tracing::{instrument, log::*};
use tracing::{
instrument,
log::{error, info},
};
use crate::{AppState, WebsiteError};
@ -52,13 +59,18 @@ pub fn routes(state: &Arc<AppState>) -> Router<Arc<AppState>> {
#[instrument(skip(state))]
pub async fn index(
State(state): State<Arc<AppState>>,
) -> std::result::Result<Html<String>, WebsiteError> {
) -> std::result::Result<Response, WebsiteError> {
let ctx = tera::Context::new();
let res = state.tera.render("index.html", &ctx).map_err(|e| {
error!("Failed rendering index: {}", e);
WebsiteError::NotFound
})?;
Ok(Html(res))
Ok((
StatusCode::OK,
[(header::LAST_MODIFIED, state.startup_time.to_rfc2822())],
Html(res),
)
.into_response())
}
async fn healthcheck() -> &'static str {
@ -101,6 +113,24 @@ pub async fn metrics_middleware<B>(request: Request<B>, next: Next<B>) -> Respon
response
}
fn should_return_304(headers: &HeaderMap, last_changed: Option<DateTime<FixedOffset>>) -> bool {
let Some(date) = last_changed else {
info!("no last modified date");
return false;
};
let Some(since) = headers.get(header::IF_MODIFIED_SINCE) else {
info!("no IF_MODIFIED_SINCE header");
return false;
};
let Ok(parsed) = DateTime::<FixedOffset>::parse_from_rfc2822(since.to_str().unwrap()) else {
info!("failed to parse IF_MODIFIED_SINCE header");
return false;
};
date > parsed
}
impl IntoResponse for WebsiteError {
fn into_response(self) -> Response {
match self {
@ -139,6 +169,7 @@ mod tests {
// aliases overlap with themselves or other routes
let posts = crate::post::load_all().await.unwrap();
let state = Arc::new(AppState {
startup_time: chrono::offset::Utc::now(),
base_url: "http://localhost:8180".into(),
tera: tera::Tera::new("templates/**/*").unwrap(),
tags: crate::tag::get_tags(posts.values()),

View file

@ -2,19 +2,24 @@ use std::sync::Arc;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
response::{Html, IntoResponse, Redirect, Response},
routing::get,
Router,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use hyper::{
header::{self, CONTENT_TYPE},
HeaderMap, StatusCode,
};
use serde_derive::Serialize;
use tracing::{instrument, log::*};
use tracing::{instrument, log::warn};
use crate::{
post::{render_post, Post},
AppState, WebsiteError,
};
use super::should_return_304;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/posts", get(|| async { Redirect::permanent("/") }))
@ -30,7 +35,7 @@ pub fn alias_router<'a>(posts: impl IntoIterator<Item = &'a Post>) -> Router<Arc
for post in posts {
for alias in &post.aliases {
let path = post.absolute_path.to_owned();
let path = post.absolute_path.clone();
router = router.route(
alias,
get(move || async {
@ -49,9 +54,18 @@ struct PageContext<'a> {
}
#[instrument(skip(state))]
pub async fn index(State(state): State<Arc<AppState>>) -> Result<Html<String>, WebsiteError> {
pub async fn index(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, WebsiteError> {
let mut posts: Vec<&Post> = state.posts.values().filter(|p| p.is_published()).collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
if should_return_304(&headers, last_changed) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
posts.sort_by_key(|p| &p.date);
posts.reverse();
@ -64,15 +78,40 @@ pub async fn index(State(state): State<Arc<AppState>>) -> Result<Html<String>, W
let res = state.tera.render("posts_index.html", &c)?;
Ok(Html(res))
let mut headers = vec![];
if let Some(date) = last_changed {
headers.push((header::LAST_MODIFIED, date.to_rfc2822()));
}
Ok((
StatusCode::OK,
[(
header::LAST_MODIFIED,
last_changed.map_or_else(
|| chrono::offset::Utc::now().to_rfc2822(),
|d| d.to_rfc2822(),
),
)],
Html(res),
)
.into_response())
}
#[instrument(skip(state))]
pub async fn view(
Path(slug): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, WebsiteError> {
headers: HeaderMap,
) -> Result<axum::response::Response, WebsiteError> {
let post = state.posts.get(&slug).ok_or(WebsiteError::NotFound)?;
let last_changed = post.last_modified();
if should_return_304(&headers, last_changed) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
if !post.is_published() {
warn!("attempted to view post before it has been published!");
return Err(WebsiteError::NotFound);
@ -80,21 +119,51 @@ pub async fn view(
let res = render_post(&state, post).await?;
Ok(Html(res))
Ok((
StatusCode::OK,
[(
header::LAST_MODIFIED,
last_changed.map_or_else(
|| chrono::offset::Utc::now().to_rfc2822(),
|d| d.to_rfc2822(),
),
)],
Html(res),
)
.into_response())
}
pub async fn feed(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, WebsiteError> {
pub async fn feed(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, WebsiteError> {
let mut posts: Vec<&Post> = state.posts.values().filter(|p| p.is_published()).collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
if should_return_304(&headers, last_changed) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
posts.sort_by_key(|p| &p.date);
posts.reverse();
posts.truncate(10);
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "application/atom+xml")],
[
(CONTENT_TYPE, "application/atom+xml"),
(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| chrono::offset::Utc::now().to_rfc2822(),
|d| d.to_rfc2822(),
),
),
],
crate::feed::render_atom_feed(&state)?,
))
)
.into_response())
}
#[instrument(skip(state))]

View file

@ -2,16 +2,21 @@ use std::sync::Arc;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
response::{Html, IntoResponse, Redirect, Response},
routing::get,
Router,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use hyper::{
header::{self, CONTENT_TYPE},
HeaderMap, StatusCode,
};
use serde_derive::Serialize;
use tracing::instrument;
use crate::{post::Post, AppState, WebsiteError};
use super::should_return_304;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/tags", get(|| async { Redirect::permanent("/") }))
@ -27,7 +32,7 @@ struct TagContext<'a> {
}
#[instrument(skip(state))]
pub async fn index(State(state): State<Arc<AppState>>) -> Result<Html<String>, WebsiteError> {
pub async fn index(State(state): State<Arc<AppState>>) -> Result<Response, WebsiteError> {
let tags: Vec<_> = state.tags.values().collect();
let ctx = TagContext { title: "Tags" };
@ -37,20 +42,32 @@ pub async fn index(State(state): State<Arc<AppState>>) -> Result<Html<String>, W
let res = state.tera.render("tags_index.html", &c)?;
Ok(Html(res))
Ok((
StatusCode::OK,
[(header::LAST_MODIFIED, state.startup_time.to_rfc2822())],
Html(res),
)
.into_response())
}
#[instrument(skip(state))]
pub async fn view(
Path(tag): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, WebsiteError> {
headers: HeaderMap,
) -> Result<Response, WebsiteError> {
let mut posts: Vec<&Post> = state
.posts
.values()
.filter(|p| p.is_published() && p.tags.contains(&tag))
.collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
if should_return_304(&headers, last_changed) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
posts.sort_by_key(|p| &p.date);
posts.reverse();
@ -65,13 +82,25 @@ pub async fn view(
let res = state.tera.render("tag.html", &c)?;
Ok(Html(res))
Ok((
StatusCode::OK,
[(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| chrono::offset::Utc::now().to_rfc2822(),
|d| d.to_rfc2822(),
),
)],
Html(res),
)
.into_response())
}
pub async fn feed(
Path(slug): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, WebsiteError> {
headers: HeaderMap,
) -> Result<Response, WebsiteError> {
let tag = state.tags.get(&slug).ok_or(WebsiteError::NotFound)?;
let mut posts: Vec<&Post> = state
@ -80,15 +109,31 @@ pub async fn feed(
.filter(|p| p.is_published() && p.tags.contains(&slug))
.collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
if should_return_304(&headers, last_changed) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
posts.sort_by_key(|p| &p.date);
posts.reverse();
posts.truncate(10);
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "application/atom+xml")],
[
(CONTENT_TYPE, "application/atom+xml"),
(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| chrono::offset::Utc::now().to_rfc2822(),
|d| d.to_rfc2822(),
),
),
],
crate::feed::render_atom_tag_feed(tag, &state)?,
))
)
.into_response())
}
#[instrument(skip(state))]

View file

@ -1,14 +1,23 @@
use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};
use tracing::error;
pub fn hilight(content: &str, lang: &str) -> color_eyre::Result<String> {
pub fn hilight(content: &str, lang: &str, theme: Option<&str>) -> color_eyre::Result<String> {
let ss = SyntaxSet::load_defaults_newlines();
let s = ss.find_syntax_by_extension(lang).unwrap_or_else(|| {
error!("Syntax not found for language: {}", lang);
ss.find_syntax_plain_text()
});
let s = ss
.find_syntax_by_extension(lang)
.or_else(|| ss.find_syntax_by_name(lang))
.unwrap_or_else(|| {
error!("Syntax not found for language: {}", lang);
ss.find_syntax_plain_text()
});
let ts = ThemeSet::load_defaults();
let theme = ts.themes.first_key_value().unwrap().1; // TODO
let theme = if let Some(t) = theme {
ts.themes
.get(t)
.unwrap_or_else(|| ts.themes.first_key_value().unwrap().1)
} else {
ts.themes.first_key_value().unwrap().1
}; // TODO
let res = syntect::html::highlighted_html_for_string(content, &ss, s, theme)?;
Ok(res)

View file

@ -1,6 +1,9 @@
#![warn(clippy::pedantic)]
#![allow(clippy::unused_async)] // axum handlers needs async, even if no awaiting happens
use std::{collections::HashMap, fmt::Display, sync::Arc, time::Duration};
use axum::extract::MatchedPath;
use chrono::DateTime;
use color_eyre::eyre::{Error, Result};
use hyper::{Body, Request, Response};
@ -9,7 +12,8 @@ use post::Post;
use tag::Tag;
use tera::Tera;
use tower_http::{compression::CompressionLayer, cors::CorsLayer};
use tracing::{info_span, log::*, Span};
use tracing::{field::Empty, info_span, log::info, Span};
use tracing_subscriber::{prelude::*, EnvFilter};
mod feed;
@ -21,6 +25,7 @@ mod tag;
#[derive(Default)]
pub struct AppState {
startup_time: DateTime<chrono::offset::Utc>,
base_url: String,
posts: HashMap<String, Post>,
tags: HashMap<String, Tag>,
@ -35,12 +40,13 @@ async fn main() -> Result<()> {
info!("Starting server...");
let base_url = option_env!("SITE_BASE_URL")
.unwrap_or("http://localhost:8180")
.unwrap_or("http://localhost:8080")
.to_string();
let tera = Tera::new("templates/**/*")?;
let posts = post::load_all().await?;
let tags = tag::get_tags(posts.values());
let state = Arc::new(AppState {
startup_time: chrono::offset::Utc::now(),
base_url,
tera,
posts,
@ -59,7 +65,7 @@ async fn main() -> Result<()> {
info!("Now listening at {}", state.base_url);
axum::Server::bind(&"0.0.0.0:8180".parse().unwrap())
axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
.serve(app.into_make_service())
.await?;
@ -82,14 +88,16 @@ fn make_span(request: &Request<Body>) -> Span {
let route = request
.extensions()
.get::<MatchedPath>()
.map(|mp| mp.as_str())
.map(axum::extract::MatchedPath::as_str)
.unwrap_or_default();
let method = request.method().as_str();
let target = uri.path_and_query().map(|p| p.as_str()).unwrap_or_default();
let target = uri
.path_and_query()
.map(axum::http::uri::PathAndQuery::as_str)
.unwrap_or_default();
let name = format!("{method} {route}");
use tracing::field::Empty;
info_span!(
"request",
otel.name = %name,
@ -123,7 +131,7 @@ impl Display for WebsiteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WebsiteError::NotFound => write!(f, "Not found"),
_ => write!(f, "Internal error"),
WebsiteError::InternalError(e) => write!(f, "Internal error: {e}"),
}
}
}

View file

@ -1,11 +1,10 @@
use color_eyre::Result;
use pulldown_cmark::Event;
use pulldown_cmark::Tag;
use pulldown_cmark::{Options, Parser};
use crate::hilighting;
pub fn render_markdown_to_html(markdown: &str) -> Result<String> {
pub fn render_markdown_to_html(markdown: &str) -> String {
let options = Options::all();
let mut content_html = String::new();
let parser = Parser::new_ext(markdown, options);
@ -33,7 +32,8 @@ pub fn render_markdown_to_html(markdown: &str) -> Result<String> {
code_block = false;
let lang = code_lang.take().unwrap_or("".into());
let res = hilighting::hilight(&code_accumulator, &lang).unwrap();
let res = hilighting::hilight(&code_accumulator, &lang, Some("base16-ocean.dark"))
.unwrap();
events.push(Event::Html(res.into()));
@ -50,5 +50,5 @@ pub fn render_markdown_to_html(markdown: &str) -> Result<String> {
pulldown_cmark::html::push_html(&mut content_html, events.into_iter());
Ok(content_html)
content_html
}

View file

@ -9,7 +9,10 @@ use regex::Regex;
use serde_derive::{Deserialize, Serialize};
use tokio::fs;
use tracing::{instrument, log::*};
use tracing::{
instrument,
log::{debug, warn},
};
use crate::{markdown, AppState, WebsiteError};
@ -38,7 +41,7 @@ pub struct Post {
impl Post {
pub fn new(slug: String, content: String, fm: TomlFrontMatter) -> Post {
Post {
absolute_path: format!("/posts/{}/", slug),
absolute_path: format!("/posts/{slug}/"),
slug,
content,
title: fm.title,
@ -60,6 +63,10 @@ impl Post {
}
true
}
pub fn last_modified(&self) -> Option<DateTime<FixedOffset>> {
self.updated.or(self.date)
}
}
#[instrument]
@ -102,9 +109,7 @@ pub async fn load_post(slug: &str) -> color_eyre::eyre::Result<Post> {
let (tomlfm, content) = parse_frontmatter(content)?;
let tomlfm = tomlfm.expect("Missing frontmatter");
let content = content
.map(|c| markdown::render_markdown_to_html(&c))
.transpose()?;
let content = content.map(|c| markdown::render_markdown_to_html(&c));
Ok(Post::new(
slug.to_string(),
@ -140,7 +145,10 @@ pub async fn render_post(state: &AppState, post: &Post) -> Result<String, Websit
ctx.insert("page", &post);
ctx.insert("base_url", &state.base_url);
state.tera.render("post.html", &ctx).map_err(|e| e.into())
state
.tera
.render("post.html", &ctx)
.map_err(std::convert::Into::into)
}
#[cfg(test)]

View file

@ -14,7 +14,7 @@ pub struct Tag {
pub fn get_tags<'a>(posts: impl IntoIterator<Item = &'a Post>) -> HashMap<String, Tag> {
let mut tags: HashMap<String, Tag> = HashMap::new();
for post in posts.into_iter() {
for post in posts {
for key in &post.tags {
if let Some(tag) = tags.get_mut(key) {
tag.posts.push(post.slug.clone());

View file

@ -2,13 +2,30 @@ body {
max-width: 800px;
margin: auto;
padding: 8px;
font-family: sans-serif;
}
.tags {
list-style: none;
padding: 0;
}
.tags > li {
display: inline-block;
margin-right: 1em;
}
img {
max-width: 100%;
}
pre {
padding: 1rem;
border-radius: 0.5em;
}
.avatar {
height: 2em;
margin-bottom: -0.5em;
}

View file

@ -3,7 +3,7 @@
{% include "partials/head.html" %}
<body>
<header>
{% include "partials/header.html" -%}
{% include "partials/header.html" -%}
</header>
<hr>
<main>

View file

@ -15,6 +15,7 @@
<li>✅ rss/atom/jsonfeed (atom is good enough for now)</li>
<li>✅ proper error handling (i guess??)</li>
<li>✅ code hilighting? (good enough for now, gotta figure out themes n' stuff later)</li>
<li>⬜ cache headers (etag, last-modified, returning 304, other related headers)
<li>⬜ sass compilation (using rsass? grass?)</li>
<li>⬜ fancy styling</li>
<li>⬜ other pages???</li>

View file

@ -1,3 +1,3 @@
<nav>
<a href="/">tollyx</a> - <a href="/posts/">posts</a>
<img src="/static/avatar.png" class="avatar"/> <a href="/">tollyx</a> - <a href="/posts/">posts</a>
</nav>