1
0
Fork 0
This commit is contained in:
Adrian Hedqvist 2024-04-17 19:12:59 +02:00
parent 82e377dbea
commit d8050d2e89
13 changed files with 639 additions and 648 deletions

905
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,16 +6,17 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.6.20", features = ["http2", "original-uri", "tracing"] }
cached = "0.46.1"
anyhow = { version = "1.0.82", features = ["backtrace"] }
axum = { version = "0.7.5", features = ["http2", "original-uri", "tracing"] }
cached = "0.49.3"
chrono = { version = "0.4.31", features = ["serde"] }
color-eyre = "0.6.1"
config = "0.13.3"
config = "0.14.0"
glob = "0.3.0"
opentelemetry = { version = "0.21.0", features = ["metrics"] }
opentelemetry-jaeger = { version = "0.20.0", features = ["collector_client", "isahc_collector_client"]}
opentelemetry = { version = "0.22.0", features = ["trace", "metrics"] }
opentelemetry-otlp = { version = "0.15.0", features = ["trace", "metrics"] }
opentelemetry_sdk = { version = "0.22.1", features = ["rt-tokio", "trace", "metrics"] }
prometheus = { version = "0.13.3", features = ["process"] }
pulldown-cmark = "0.9.2"
pulldown-cmark = "0.10.2"
regex = "1.7.2"
serde = "1.0.144"
serde_derive = "1.0.144"
@ -25,7 +26,7 @@ tera = { version = "1.19.1", features = ["builtins"] }
tokio = { version = "1.34.0", features = ["full", "tracing"] }
toml = "0.8.8"
tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.4.4", features = ["full"] }
tower-http = { version = "0.5.2", features = ["full"] }
tracing = "0.1.35"
tracing-opentelemetry = "0.22.0"
tracing-opentelemetry = "0.23.0"
tracing-subscriber = { version = "0.3.17", features = ["fmt", "env-filter", "json", "tracing-log"] }

18
fly.toml Normal file
View file

@ -0,0 +1,18 @@
# fly.toml app configuration file generated for cool-glade-6208 on 2023-06-24T10:46:20+02:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "cool-glade-6208"
primary_region = "arn"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[metrics]
port = 8180
path = "/metrics"

View file

@ -1,4 +1,4 @@
use color_eyre::Result;
use anyhow::Result;
use serde_derive::Serialize;
use tracing::instrument;
@ -16,7 +16,11 @@ struct FeedContext<'a> {
#[instrument(skip(state))]
pub fn render_atom_feed(state: &AppState) -> Result<String> {
let mut posts: Vec<_> = state.posts.values().filter(|p| !p.draft && p.is_published()).collect();
let mut posts: Vec<_> = state
.posts
.values()
.filter(|p| !p.draft && p.is_published())
.collect();
posts.sort_by_key(|p| &p.date);
posts.reverse();

View file

@ -1,7 +1,7 @@
use axum::{
body,
extract::State,
http::{header, HeaderMap, Request, StatusCode},
body::Body,
extract::{Request, State},
http::{header, HeaderMap, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Response},
routing::get,
@ -14,7 +14,7 @@ use std::sync::Arc;
use tower_http::services::ServeDir;
use tracing::{
instrument,
log::{error, info, debug},
log::{debug, error, info},
};
use crate::{AppState, WebsiteError};
@ -22,14 +22,16 @@ use crate::{AppState, WebsiteError};
pub mod posts;
pub mod tags;
pub static HIT_COUNTER: Lazy<IntCounterVec> = Lazy::new(|| prometheus::register_int_counter_vec!(
opts!(
"http_requests_total",
"Total amount of http requests received"
),
&["route", "method", "status"]
)
.unwrap());
pub static HIT_COUNTER: Lazy<IntCounterVec> = Lazy::new(|| {
prometheus::register_int_counter_vec!(
opts!(
"http_requests_total",
"Total amount of http requests received"
),
&["route", "method", "status"]
)
.unwrap()
});
pub fn routes(state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
@ -38,14 +40,8 @@ pub fn routes(state: &Arc<AppState>) -> Router<Arc<AppState>> {
.merge(tags::router())
.merge(posts::alias_router(state.posts.values()))
.route("/healthcheck", get(healthcheck))
.route_service(
"/posts/:slug/*path",
ServeDir::new("./"),
)
.route_service(
"/static/*path",
ServeDir::new("./"),
)
.route_service("/posts/:slug/*path", ServeDir::new("./"))
.route_service("/static/*path", ServeDir::new("./"))
.layer(axum::middleware::from_fn(metrics_middleware))
.route("/metrics", get(metrics))
}
@ -86,7 +82,7 @@ async fn metrics() -> impl IntoResponse {
Response::builder()
.status(200)
.header(header::CONTENT_TYPE, encoder.format_type())
.body(body::boxed(body::Full::from(buffer)))
.body(Body::from(buffer))
.unwrap()
}
@ -95,7 +91,7 @@ pub async fn not_found() -> impl IntoResponse {
}
#[instrument(skip(request, next))]
pub async fn metrics_middleware<B>(request: Request<B>, next: Next<B>) -> Response {
pub async fn metrics_middleware(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string();
let method = request.method().clone();

View file

@ -37,8 +37,8 @@ pub fn alias_router<'a>(posts: impl IntoIterator<Item = &'a Post>) -> Router<Arc
router = router.route(
alias,
get(move || async {
let path = path;
Redirect::permanent(&path)
let p = path;
Redirect::permanent(&p)
}),
);
}
@ -56,7 +56,11 @@ 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.draft && p.is_published()).collect();
let mut posts: Vec<&Post> = state
.posts
.values()
.filter(|p| !p.draft && p.is_published())
.collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
@ -76,15 +80,11 @@ pub async fn index(
let res = state.tera.render("posts_index.html", &c)?;
Ok((
StatusCode::OK,
[(
header::LAST_MODIFIED,
last_changed.map_or_else(
|| state.startup_time.to_rfc2822(),
|d| d.to_rfc2822(),
),
last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()),
)],
Html(res),
)
@ -116,10 +116,7 @@ pub async fn view(
StatusCode::OK,
[(
header::LAST_MODIFIED,
last_changed.map_or_else(
|| state.startup_time.to_rfc2822(),
|d| d.to_rfc2822(),
),
last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()),
)],
Html(res),
)
@ -131,7 +128,11 @@ 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.draft && p.is_published()).collect();
let mut posts: Vec<&Post> = state
.posts
.values()
.filter(|p| !p.draft && p.is_published())
.collect();
let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
@ -149,10 +150,7 @@ pub async fn feed(
(header::CONTENT_TYPE, "application/atom+xml"),
(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| state.startup_time.to_rfc2822(),
|d| d.to_rfc2822(),
),
&last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()),
),
],
crate::feed::render_atom_feed(&state)?,

View file

@ -85,10 +85,7 @@ pub async fn view(
StatusCode::OK,
[(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| state.startup_time.to_rfc2822(),
|d| d.to_rfc2822(),
),
&last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()),
)],
Html(res),
)
@ -125,10 +122,7 @@ pub async fn feed(
(header::CONTENT_TYPE, "application/atom+xml"),
(
header::LAST_MODIFIED,
&last_changed.map_or_else(
|| state.startup_time.to_rfc2822(),
|d| d.to_rfc2822(),
),
&last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()),
),
],
crate::feed::render_atom_tag_feed(tag, &state)?,

View file

@ -36,23 +36,32 @@ pub fn uri_with_path(uri: &Uri, path: &str) -> Uri {
mod tests {
use axum::http::Uri;
use crate::helpers::uri_with_path;
use super::*;
#[test]
fn uri_with_relative_path() {
let uri: Uri = "http://localhost/baba/".parse().unwrap();
assert_eq!(uri_with_path(&uri, "is/you").to_string(), "http://localhost/baba/is/you");
assert_eq!(
uri_with_path(&uri, "is/you").to_string(),
"http://localhost/baba/is/you"
);
}
#[test]
fn uri_with_absolute_path() {
let uri: Uri = "http://localhost/baba/is/you".parse().unwrap();
assert_eq!(uri_with_path(&uri, "/keke/is/move").to_string(), "http://localhost/keke/is/move");
assert_eq!(
uri_with_path(&uri, "/keke/is/move").to_string(),
"http://localhost/keke/is/move"
);
}
#[test]
fn uri_with_index_relative_path() {
let uri: Uri = "http://localhost/baba/is/you".parse().unwrap();
assert_eq!(uri_with_path(&uri, "happy").to_string(), "http://localhost/baba/is/happy");
assert_eq!(
uri_with_path(&uri, "happy/and/you").to_string(),
"http://localhost/baba/is/happy/and/you"
);
}
}

View file

@ -2,7 +2,7 @@ use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};
use tracing::{error, instrument};
#[instrument(skip(content, lang, theme))]
pub fn hilight(content: &str, lang: &str, theme: Option<&str>) -> color_eyre::Result<String> {
pub fn hilight(content: &str, lang: &str, theme: Option<&str>) -> anyhow::Result<String> {
let ss = SyntaxSet::load_defaults_newlines();
let s = ss
.find_syntax_by_extension(lang)

View file

@ -1,31 +1,26 @@
#![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 std::{collections::HashMap, fmt::Display, sync::Arc};
use axum::{
body::Body,
extract::{MatchedPath, OriginalUri},
http::{uri::PathAndQuery, Request, Uri},
response::Response,
};
use axum::http::Uri;
use chrono::DateTime;
use color_eyre::eyre::{Error, Result};
use config::Config;
use post::Post;
use tag::Tag;
use tera::Tera;
use tower_http::{compression::CompressionLayer, cors::CorsLayer};
use tracing::{field::Empty, info_span, log::info, Span, instrument};
use tracing_subscriber::{prelude::*, EnvFilter};
use tower_http::{compression::CompressionLayer, cors::CorsLayer};
use tracing::{instrument, log::info};
use anyhow::{Error, Result};
mod feed;
mod handlers;
mod helpers;
mod hilighting;
mod markdown;
mod observability;
mod post;
mod tag;
@ -40,28 +35,24 @@ pub struct AppState {
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let cfg = Config::builder()
.add_source(config::File::with_name("config.toml"))
.add_source(config::Environment::with_prefix("WEBSITE"))
.build()?;
init_tracing(&cfg);
observability::init_tracing(&cfg);
info!("Starting server...");
let addr = cfg.get_string("bind_address")?;
let app = init_app(&cfg).await?;
axum::Server::bind(&cfg.get_string("bind_address")?.parse().unwrap())
.serve(app.into_make_service())
.await?;
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
#[instrument]
async fn init_app(cfg: &Config) -> Result<axum::routing::Router> {
let base_url: Uri = cfg.get_string("base_url")?
.parse()
.unwrap();
let base_url: Uri = cfg.get_string("base_url")?.parse().unwrap();
let tera = Tera::new("templates/**/*")?;
let mut state = AppState {
@ -83,69 +74,12 @@ async fn init_app(cfg: &Config) -> Result<axum::routing::Router> {
.layer(CompressionLayer::new())
.layer(
tower_http::trace::TraceLayer::new_for_http()
.make_span_with(make_span)
.on_response(on_response),
.make_span_with(observability::make_span)
.on_response(observability::on_response),
)
.with_state(state.clone()))
}
fn init_tracing(cfg: &Config) {
let filter = if let Ok(filter) = cfg.get_string("logging") {
EnvFilter::builder()
.with_default_directive("info".parse().unwrap())
.parse_lossy(filter)
} else {
EnvFilter::builder()
.with_default_directive("info".parse().unwrap())
.from_env_lossy()
};
opentelemetry::global::set_text_map_propagator(opentelemetry_jaeger::Propagator::new());
let tracer = opentelemetry_jaeger::new_agent_pipeline()
.with_service_name("website")
.install_simple().unwrap();
let otel = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(filter)
.with(otel)
.with(tracing_subscriber::fmt::layer())
.init();
}
fn make_span(request: &Request<Body>) -> Span {
let uri = if let Some(OriginalUri(uri)) = request.extensions().get::<OriginalUri>() {
uri
} else {
request.uri()
};
let route = request
.extensions()
.get::<MatchedPath>()
.map_or(uri.path(), axum::extract::MatchedPath::as_str);
let method = request.method().as_str();
let target = uri
.path_and_query()
.map_or(uri.path(), PathAndQuery::as_str);
let name = format!("{method} {route}");
info_span!(
"request",
otel.name = %name,
http.route = %route,
http.method = %method,
http.target = %target,
http.status_code = Empty
)
}
fn on_response(response: &Response, _latency: Duration, span: &Span) {
span.record("http.status_code", response.status().as_str());
}
#[derive(Debug)]
pub enum WebsiteError {
NotFound,
@ -176,8 +110,8 @@ impl From<tera::Error> for WebsiteError {
}
}
impl From<color_eyre::Report> for WebsiteError {
fn from(value: color_eyre::Report) -> Self {
impl From<Error> for WebsiteError {
fn from(value: Error) -> Self {
WebsiteError::InternalError(value)
}
}

View file

@ -2,8 +2,10 @@ use crate::helpers;
use crate::hilighting;
use axum::http::Uri;
use cached::once_cell::sync::Lazy;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::Event;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use pulldown_cmark::{Options, Parser};
use regex::Regex;
use tracing::instrument;
@ -24,46 +26,65 @@ pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> String
let mut content_html = String::new();
let parser = Parser::new_ext(markdown, opt);
let mut code_block = false;
let mut code_lang = None;
let mut code_accumulator = String::new();
let mut events = Vec::new();
for event in parser {
match event {
Event::Text(text) => {
if code_block {
if code_lang.is_some() {
code_accumulator.push_str(&text);
} else {
events.push(Event::Text(text));
}
}
Event::Start(Tag::Link(t, mut link, title)) => {
Event::Start(Tag::Link {
mut dest_url,
link_type,
title,
id,
}) => {
if let Some(uri) = base_uri {
if !link.starts_with('#') && !STARTS_WITH_SCHEMA_RE.is_match(&link) && !EMAIL_RE.is_match(&link) {
if !dest_url.starts_with('#')
&& !STARTS_WITH_SCHEMA_RE.is_match(&dest_url)
&& !EMAIL_RE.is_match(&dest_url)
{
// convert relative URIs to absolute URIs
link = helpers::uri_with_path(uri, &link).to_string().into();
dest_url = helpers::uri_with_path(uri, &dest_url).to_string().into();
}
}
events.push(Event::Start(Tag::Link(t, link, title)));
events.push(Event::Start(Tag::Link {
dest_url,
link_type,
title,
id,
}));
}
Event::Start(Tag::Image(t, mut link, title)) => {
Event::Start(Tag::Image {
link_type,
mut dest_url,
title,
id,
}) => {
if let Some(uri) = base_uri {
if !link.contains("://") && !link.contains('@') {
if !dest_url.contains("://") && !dest_url.contains('@') {
// convert relative URIs to absolute URIs
link = helpers::uri_with_path(uri, &link).to_string().into();
dest_url = helpers::uri_with_path(uri, &dest_url).to_string().into();
}
}
events.push(Event::Start(Tag::Image(t, link, title)));
events.push(Event::Start(Tag::Image {
link_type,
dest_url,
title,
id,
}));
}
Event::Start(Tag::CodeBlock(kind)) => {
code_block = true;
if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
if let CodeBlockKind::Fenced(lang) = kind {
code_lang = Some(lang);
}
}
Event::End(Tag::CodeBlock(_)) => {
code_block = false;
Event::End(TagEnd::CodeBlock) => {
let lang = code_lang.take().unwrap_or("".into());
let res = hilighting::hilight(&code_accumulator, &lang, Some("base16-ocean.dark"))
.unwrap();

70
src/observability.rs Normal file
View file

@ -0,0 +1,70 @@
use std::time::Duration;
use axum::{
extract::{MatchedPath, OriginalUri, Request},
http::uri::PathAndQuery,
response::Response,
};
use config::Config;
use opentelemetry::global;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use tracing::{field::Empty, info_span, Span};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init_tracing(cfg: &Config) {
let filter = if let Ok(filter) = cfg.get_string("logging") {
EnvFilter::builder()
.with_default_directive("info".parse().unwrap())
.parse_lossy(filter)
} else {
EnvFilter::builder()
.with_default_directive("info".parse().unwrap())
.from_env_lossy()
};
global::set_text_map_propagator(TraceContextPropagator::new());
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(opentelemetry_otlp::new_exporter().tonic())
.install_batch(opentelemetry_sdk::runtime::Tokio)
.unwrap();
let otel = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(filter)
.with(otel)
.with(tracing_subscriber::fmt::layer())
.init();
}
pub fn make_span(request: &Request) -> Span {
let uri = if let Some(OriginalUri(uri)) = request.extensions().get::<OriginalUri>() {
uri
} else {
request.uri()
};
let route = request
.extensions()
.get::<MatchedPath>()
.map_or(uri.path(), axum::extract::MatchedPath::as_str);
let method = request.method().as_str();
let target = uri
.path_and_query()
.map_or(uri.path(), PathAndQuery::as_str);
let name = format!("{method} {route}");
info_span!(
"request",
otel.name = %name,
http.route = %route,
http.method = %method,
http.target = %target,
http.status_code = Empty
)
}
pub fn on_response(response: &Response, _latency: Duration, span: &Span) {
span.record("http.status_code", response.status().as_str());
}

View file

@ -1,5 +1,7 @@
use std::{collections::HashMap, path::Path};
use anyhow::Result;
use cached::once_cell::sync::Lazy;
use chrono::{DateTime, FixedOffset};
use glob::glob;
@ -15,10 +17,9 @@ use tracing::{
use crate::{helpers, markdown, AppState, WebsiteError};
static FRONTMATTER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(
r"^[\s]*\+{3}(\r?\n(?s).*?(?-s))\+{3}[\s]*(?:$|(?:\r?\n((?s).*(?-s))$))"
)
.unwrap());
static FRONTMATTER_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[\s]*\+{3}(\r?\n(?s).*?(?-s))\+{3}[\s]*(?:$|(?:\r?\n((?s).*(?-s))$))").unwrap()
});
#[derive(Deserialize, Debug, Default)]
pub struct TomlFrontMatter {
@ -76,7 +77,7 @@ impl Post {
}
#[instrument(skip(state))]
pub async fn load_all(state: &AppState) -> color_eyre::eyre::Result<HashMap<String, Post>> {
pub async fn load_all(state: &AppState) -> Result<HashMap<String, Post>> {
let mut res = HashMap::<String, Post>::new();
for path in glob("posts/**/*.md")? {
let path = path.unwrap();
@ -101,7 +102,7 @@ pub async fn load_all(state: &AppState) -> color_eyre::eyre::Result<HashMap<Stri
}
#[instrument(skip(state))]
pub async fn load_post(state: &AppState, slug: &str) -> color_eyre::eyre::Result<Post> {
pub async fn load_post(state: &AppState, slug: &str) -> Result<Post> {
debug!("loading post: {slug}");
let file_path = Path::new("posts").join(slug);
@ -127,9 +128,7 @@ pub async fn load_post(state: &AppState, slug: &str) -> color_eyre::eyre::Result
}
#[instrument(skip(src))]
fn parse_frontmatter(
src: String,
) -> color_eyre::eyre::Result<(Option<TomlFrontMatter>, Option<String>)> {
fn parse_frontmatter(src: String) -> Result<(Option<TomlFrontMatter>, Option<String>)> {
Ok(if let Some(captures) = FRONTMATTER_REGEX.captures(&src) {
(
Some(toml::from_str(captures.get(1).unwrap().as_str())?),