1
0
Fork 0

more stuff

This commit is contained in:
Adrian Hedqvist 2024-04-19 22:25:12 +02:00
parent bdc291b539
commit 8efba600f5
7 changed files with 159 additions and 73 deletions

View file

@ -6,6 +6,8 @@ services:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
- otel-collector - otel-collector
environment:
TLX_OTLP_ENABLED: true
otel-collector: otel-collector:
image: otel/opentelemetry-collector:latest image: otel/opentelemetry-collector:latest
restart: always restart: always

View file

@ -8,13 +8,10 @@ use axum::{
}; };
use chrono::{DateTime, FixedOffset}; use chrono::{DateTime, FixedOffset};
use opentelemetry::{global, metrics::Counter, KeyValue}; use opentelemetry::{global, metrics::Counter, KeyValue};
use tokio::sync::OnceCell;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::OnceCell;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::{ use tracing::{instrument, log::error};
instrument,
log::{debug, error, info},
};
use crate::{AppState, WebsiteError}; use crate::{AppState, WebsiteError};
@ -24,9 +21,9 @@ pub mod tags;
pub static HIT_COUNTER: OnceCell<Counter<u64>> = OnceCell::const_new(); pub static HIT_COUNTER: OnceCell<Counter<u64>> = OnceCell::const_new();
async fn record_hit(method: String, path: String) { async fn record_hit(method: String, path: String) {
let counter = HIT_COUNTER.get_or_init(|| async { let counter = HIT_COUNTER
global::meter("tlxite").u64_counter("page_hit_count").init() .get_or_init(|| async { global::meter("tlxite").u64_counter("page_hit_count").init() })
}).await; .await;
counter.add(1, &[KeyValue::new("path", format!("{method} {path}"))]); counter.add(1, &[KeyValue::new("path", format!("{method} {path}"))]);
} }
@ -36,6 +33,7 @@ pub fn routes(state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/", get(index)) .route("/", get(index))
.merge(pages::router()) .merge(pages::router())
.merge(tags::router()) .merge(tags::router())
// .merge(pages::pages_router(state.pages.values()))
.merge(pages::alias_router(state.pages.values())) .merge(pages::alias_router(state.pages.values()))
.route("/healthcheck", get(healthcheck)) .route("/healthcheck", get(healthcheck))
.route_service("/posts/:slug/*path", ServeDir::new("./")) .route_service("/posts/:slug/*path", ServeDir::new("./"))
@ -59,7 +57,13 @@ pub async fn index(
})?; })?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
[(header::LAST_MODIFIED, state.startup_time.to_rfc2822())], [
(
header::LAST_MODIFIED,
state.startup_time.to_rfc2822().as_str(),
),
(header::CACHE_CONTROL, "no-cache"),
],
Html(res), Html(res),
) )
.into_response()) .into_response())
@ -89,29 +93,23 @@ pub async fn metrics_middleware(request: Request, next: Next) -> Response {
fn should_return_304(headers: &HeaderMap, last_changed: Option<DateTime<FixedOffset>>) -> bool { fn should_return_304(headers: &HeaderMap, last_changed: Option<DateTime<FixedOffset>>) -> bool {
let Some(date) = last_changed else { let Some(date) = last_changed else {
debug!("no last modified date");
return false; return false;
}; };
let Some(since) = headers.get(header::IF_MODIFIED_SINCE) else { let Some(since) = headers.get(header::IF_MODIFIED_SINCE) else {
debug!("no IF_MODIFIED_SINCE header");
return false; return false;
}; };
let Ok(parsed) = DateTime::<FixedOffset>::parse_from_rfc2822(since.to_str().unwrap()) else { let Ok(parsed) = DateTime::<FixedOffset>::parse_from_rfc2822(since.to_str().unwrap()) else {
debug!("failed to parse IF_MODIFIED_SINCE header");
return false; return false;
}; };
date > parsed date >= parsed
} }
impl IntoResponse for WebsiteError { impl IntoResponse for WebsiteError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
WebsiteError::NotFound => { WebsiteError::NotFound => (StatusCode::NOT_FOUND, ()).into_response(),
info!("not found");
(StatusCode::NOT_FOUND, ()).into_response()
}
WebsiteError::InternalError(e) => { WebsiteError::InternalError(e) => {
if let Some(s) = e.source() { if let Some(s) = e.source() {
error!("internal error: {}: {}", e, s); error!("internal error: {}: {}", e, s);
@ -148,7 +146,9 @@ mod tests {
}; };
// Load the actual posts, just to make this test fail if // Load the actual posts, just to make this test fail if
// aliases overlap with themselves or other routes // aliases overlap with themselves or other routes
let posts = crate::page::load_all(&state, "posts/".into()).await.unwrap(); let posts = crate::page::load_all(&state, "posts/".into())
.await
.unwrap();
state.tags = crate::tag::get_tags(posts.values()); state.tags = crate::tag::get_tags(posts.values());
state.pages = posts; state.pages = posts;
let state = Arc::new(state); let state = Arc::new(state);

View file

@ -2,17 +2,17 @@ use std::sync::Arc;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::{header, HeaderMap, StatusCode}, http::{self, header, HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
routing::get, routing::get,
Router, Router,
}; };
use serde_derive::Serialize; use serde_derive::Serialize;
use tracing::{instrument, log::warn}; use tracing::instrument;
use crate::{ use crate::{
page::{render_post, Page}, page::{render_page, Page},
AppState, WebsiteError, AppState, WebsiteError,
}; };
@ -28,10 +28,39 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/posts/:slug/index.md", get(super::not_found)) .route("/posts/:slug/index.md", get(super::not_found))
} }
pub fn alias_router<'a>(posts: impl IntoIterator<Item = &'a Page>) -> Router<Arc<AppState>> { // pub fn pages_router<'a>(pages: impl IntoIterator<Item = &'a Page>) -> Router<Arc<AppState>> {
// let mut router = Router::new();
// for post in pages {
// let slug = post.slug.clone();
// router = router.route(
// &post.absolute_path,
// get(
// move |state: State<Arc<AppState>>,
// method: http::method::Method,
// headers: HeaderMap| async {
// view(Path(slug), state, method, headers).await
// },
// ),
// );
// for alias in &post.aliases {
// let path = post.absolute_path.clone();
// router = router.route(
// alias,
// get(move || async {
// let p = path;
// Redirect::permanent(&p)
// }),
// );
// }
// }
// router
// }
pub fn alias_router<'a>(pages: impl IntoIterator<Item = &'a Page>) -> Router<Arc<AppState>> {
let mut router = Router::new(); let mut router = Router::new();
for post in posts { for post in pages {
for alias in &post.aliases { for alias in &post.aliases {
let path = post.absolute_path.clone(); let path = post.absolute_path.clone();
router = router.route( router = router.route(
@ -82,10 +111,15 @@ async fn index(
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
[( [
header::LAST_MODIFIED, (
last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()), header::LAST_MODIFIED,
)], last_changed
.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822())
.as_str(),
),
(header::CACHE_CONTROL, "no-cache"),
],
Html(res), Html(res),
) )
.into_response()) .into_response())
@ -95,29 +129,42 @@ async fn index(
async fn view( async fn view(
Path(slug): Path<String>, Path(slug): Path<String>,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
method: http::method::Method,
headers: HeaderMap, headers: HeaderMap,
) -> Result<axum::response::Response, WebsiteError> { ) -> Result<axum::response::Response, WebsiteError> {
let post = state.pages.get(&slug).ok_or(WebsiteError::NotFound)?; let post = state.pages.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() { if !post.is_published() {
warn!("attempted to view post before it has been published!");
return Err(WebsiteError::NotFound); return Err(WebsiteError::NotFound);
} }
let res = render_post(&state, post).await?; if let Some(etag) = headers.get(header::IF_NONE_MATCH) {
if let Ok(etag) = etag.to_str() {
if etag == post.etag {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
}
}
if method == http::method::Method::HEAD {
return Ok((
StatusCode::OK,
[
(header::ETAG, post.etag.as_str()),
(header::CACHE_CONTROL, "no-cache"),
],
)
.into_response());
}
let res = render_page(&state, post).await?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
[( [
header::LAST_MODIFIED, (header::ETAG, post.etag.as_str()),
last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()), (header::CACHE_CONTROL, "no-cache"),
)], ],
Html(res), Html(res),
) )
.into_response()) .into_response())

View file

@ -10,9 +10,9 @@ use settings::Settings;
use tag::Tag; use tag::Tag;
use tera::Tera; use tera::Tera;
use tokio::net::TcpListener; use tokio::{net::TcpListener, signal};
use tower_http::{compression::CompressionLayer, cors::CorsLayer}; use tower_http::{compression::CompressionLayer, cors::CorsLayer};
use tracing::{instrument, log::info}; use tracing::{debug, instrument, log::info};
use anyhow::{Error, Result}; use anyhow::{Error, Result};
@ -44,7 +44,10 @@ async fn main() -> Result<()> {
info!("Starting server..."); info!("Starting server...");
let app = init_app(&cfg).await?; let app = init_app(&cfg).await?;
let listener = TcpListener::bind(&cfg.bind_address).await.unwrap(); let listener = TcpListener::bind(&cfg.bind_address).await.unwrap();
axum::serve(listener, app.into_make_service()).await?;
axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await?;
opentelemetry::global::shutdown_tracer_provider(); opentelemetry::global::shutdown_tracer_provider();
@ -63,9 +66,14 @@ async fn init_app(cfg: &Settings) -> Result<axum::routing::Router> {
..Default::default() ..Default::default()
}; };
let posts = page::load_all(&state, "pages/".into()).await?; let pages = page::load_all(&state, "posts/".into()).await?;
let tags = tag::get_tags(posts.values()); info!("{} pages loaded", pages.len());
state.pages = posts; for page in pages.values() {
debug!("slug: {}, path: {}", page.slug, page.absolute_path);
}
let tags = tag::get_tags(pages.values());
state.pages = pages;
state.tags = tags; state.tags = tags;
let state = Arc::new(state); let state = Arc::new(state);
@ -81,6 +89,30 @@ async fn init_app(cfg: &Settings) -> Result<axum::routing::Router> {
.with_state(state.clone())) .with_state(state.clone()))
} }
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum WebsiteError { pub enum WebsiteError {
NotFound, NotFound,

View file

@ -16,7 +16,7 @@ static EMAIL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^.+?@\w+(\.\w+)*$").unw
pub struct RenderResult { pub struct RenderResult {
pub content_html: String, pub content_html: String,
pub metadata: String pub metadata: String,
} }
#[instrument(skip(markdown))] #[instrument(skip(markdown))]

View file

@ -9,7 +9,10 @@ use axum::{
use opentelemetry::{global, KeyValue}; use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::WithExportConfig; use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{ use opentelemetry_sdk::{
metrics::reader::{DefaultAggregationSelector, DefaultTemporalitySelector}, propagation::TraceContextPropagator, trace::{RandomIdGenerator, Sampler}, Resource metrics::reader::{DefaultAggregationSelector, DefaultTemporalitySelector},
propagation::TraceContextPropagator,
trace::{RandomIdGenerator, Sampler},
Resource,
}; };
use tracing::{field::Empty, info_span, Span}; use tracing::{field::Empty, info_span, Span};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@ -21,8 +24,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> {
.with_default_directive("info".parse()?) .with_default_directive("info".parse()?)
.parse_lossy(&cfg.logging); .parse_lossy(&cfg.logging);
if cfg.otlp.enabled { if cfg.otlp.enabled {
let tracer = opentelemetry_otlp::new_pipeline() let tracer = opentelemetry_otlp::new_pipeline()
.tracing() .tracing()
@ -42,7 +43,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> {
global::set_text_map_propagator(TraceContextPropagator::new()); global::set_text_map_propagator(TraceContextPropagator::new());
let otel_tracer = tracing_opentelemetry::layer().with_tracer(tracer); let otel_tracer = tracing_opentelemetry::layer().with_tracer(tracer);
let meter = opentelemetry_otlp::new_pipeline() let meter = opentelemetry_otlp::new_pipeline()
.metrics(opentelemetry_sdk::runtime::Tokio) .metrics(opentelemetry_sdk::runtime::Tokio)
.with_exporter( .with_exporter(
@ -59,8 +59,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> {
global::set_meter_provider(meter); global::set_meter_provider(meter);
// let logger = opentelemetry_otlp::new_pipeline() // let logger = opentelemetry_otlp::new_pipeline()
// .logging() // .logging()
// .with_exporter( // .with_exporter(
@ -76,17 +74,13 @@ pub fn init(cfg: &Settings) -> Result<(), Error> {
.with(otel_tracer) .with(otel_tracer)
.with(tracing_subscriber::fmt::layer().compact()) .with(tracing_subscriber::fmt::layer().compact())
.init(); .init();
} } else {
else {
tracing_subscriber::registry() tracing_subscriber::registry()
.with(filter) .with(filter)
.with(tracing_subscriber::fmt::layer().compact()) .with(tracing_subscriber::fmt::layer().compact())
.init(); .init();
} }
Ok(()) Ok(())
} }

View file

@ -1,4 +1,9 @@
use std::{collections::HashMap, fmt::Debug, path::{Path, PathBuf}}; use std::{
collections::HashMap,
fmt::Debug,
hash::{Hash, Hasher},
path::{Path, PathBuf},
};
use anyhow::Result; use anyhow::Result;
@ -7,10 +12,7 @@ use chrono::{DateTime, FixedOffset};
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use tokio::fs; use tokio::fs;
use tracing::{ use tracing::{instrument, log::debug};
instrument,
log::debug,
};
use crate::{helpers, markdown, AppState, WebsiteError}; use crate::{helpers, markdown, AppState, WebsiteError};
@ -35,13 +37,19 @@ pub struct Page {
pub content: String, pub content: String,
pub slug: String, pub slug: String,
pub absolute_path: String, pub absolute_path: String,
pub etag: String,
} }
impl Page { impl Page {
pub fn new(slug: String, content: String, fm: TomlFrontMatter) -> Page { pub fn new(slug: String, content: String, fm: TomlFrontMatter) -> Page {
let mut hasher = std::hash::DefaultHasher::default();
content.hash(&mut hasher);
let etag = format!("W/\"{:x}\"", hasher.finish());
Page { Page {
absolute_path: format!("/posts/{slug}/"), absolute_path: format!("/posts/{slug}/"),
slug, slug,
etag,
content, content,
title: fm.title, title: fm.title,
draft: fm.draft.unwrap_or(false), draft: fm.draft.unwrap_or(false),
@ -81,8 +89,7 @@ pub async fn load_all(state: &AppState, folder: PathBuf) -> Result<HashMap<Strin
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
dirs.push(path); dirs.push(path);
} } else if let Some(ext) = path.extension() {
else if let Some(ext) = path.extension() {
if ext == "md" { if ext == "md" {
// it's a page to load // it's a page to load
let page = load_page(state, &path).await?; let page = load_page(state, &path).await?;
@ -105,23 +112,27 @@ pub async fn load_all(state: &AppState, folder: PathBuf) -> Result<HashMap<Strin
#[instrument(skip(state))] #[instrument(skip(state))]
pub async fn load_page(state: &AppState, path: &Path) -> Result<Page> { pub async fn load_page(state: &AppState, path: &Path) -> Result<Page> {
debug!("loading post: {path:?}"); debug!("loading page: {path:?}");
let content = fs::read_to_string(path).await?; let content = fs::read_to_string(path).await?;
let path_str = path.to_string_lossy().replace('\\', "/"); let path_str = path.to_string_lossy().replace('\\', "/");
let slug = path_str let slug = path_str
.trim_start_matches("posts/") .trim_start_matches("posts")
.trim_start_matches('/') .trim_start_matches('/')
.trim_end_matches(".html") .trim_end_matches(".html")
.trim_end_matches(".md") .trim_end_matches(".md")
.trim_end_matches("index") .trim_end_matches("index")
.trim_end_matches('/'); .trim_end_matches('/');
let base_uri = helpers::uri_with_path(&state.base_url, &format!("/{slug}/")); let base_path = if let Some(i) = path_str.rfind('/') {
&path_str[..=i]
} else {
&path_str
};
let base_uri = helpers::uri_with_path(&state.base_url, base_path);
let content = markdown::render_markdown_to_html(Some(&base_uri), &content); let content = markdown::render_markdown_to_html(Some(&base_uri), &content);
@ -132,10 +143,10 @@ pub async fn load_page(state: &AppState, path: &Path) -> Result<Page> {
)) ))
} }
#[instrument(skip(state, post))] #[instrument(skip(state, page))]
pub async fn render_post(state: &AppState, post: &Page) -> Result<String, WebsiteError> { pub async fn render_page(state: &AppState, page: &Page) -> Result<String, WebsiteError> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("page", &post); ctx.insert("page", &page);
ctx.insert("base_url", &state.base_url.to_string()); ctx.insert("base_url", &state.base_url.to_string());
state state
@ -160,7 +171,7 @@ mod tests {
state.pages = super::load_all(&state, "posts/".into()).await.unwrap(); state.pages = super::load_all(&state, "posts/".into()).await.unwrap();
for post in state.pages.values() { for post in state.pages.values() {
super::render_post(&state, post).await.unwrap(); super::render_page(&state, post).await.unwrap();
} }
} }
} }