diff --git a/compose.yaml b/compose.yaml index dd791b8..4381ea8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,6 +6,8 @@ services: - "8080:8080" depends_on: - otel-collector + environment: + TLX_OTLP_ENABLED: true otel-collector: image: otel/opentelemetry-collector:latest restart: always diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index b356691..7c96c3f 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -8,13 +8,10 @@ use axum::{ }; use chrono::{DateTime, FixedOffset}; use opentelemetry::{global, metrics::Counter, KeyValue}; -use tokio::sync::OnceCell; use std::sync::Arc; +use tokio::sync::OnceCell; use tower_http::services::ServeDir; -use tracing::{ - instrument, - log::{debug, error, info}, -}; +use tracing::{instrument, log::error}; use crate::{AppState, WebsiteError}; @@ -24,9 +21,9 @@ pub mod tags; pub static HIT_COUNTER: OnceCell> = OnceCell::const_new(); async fn record_hit(method: String, path: String) { - let counter = HIT_COUNTER.get_or_init(|| async { - global::meter("tlxite").u64_counter("page_hit_count").init() - }).await; + let counter = HIT_COUNTER + .get_or_init(|| async { global::meter("tlxite").u64_counter("page_hit_count").init() }) + .await; counter.add(1, &[KeyValue::new("path", format!("{method} {path}"))]); } @@ -36,6 +33,7 @@ pub fn routes(state: &Arc) -> Router> { .route("/", get(index)) .merge(pages::router()) .merge(tags::router()) + // .merge(pages::pages_router(state.pages.values())) .merge(pages::alias_router(state.pages.values())) .route("/healthcheck", get(healthcheck)) .route_service("/posts/:slug/*path", ServeDir::new("./")) @@ -59,7 +57,13 @@ pub async fn index( })?; 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), ) .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>) -> bool { let Some(date) = last_changed else { - debug!("no last modified date"); return false; }; let Some(since) = headers.get(header::IF_MODIFIED_SINCE) else { - debug!("no IF_MODIFIED_SINCE header"); return false; }; let Ok(parsed) = DateTime::::parse_from_rfc2822(since.to_str().unwrap()) else { - debug!("failed to parse IF_MODIFIED_SINCE header"); return false; }; - date > parsed + date >= parsed } impl IntoResponse for WebsiteError { fn into_response(self) -> Response { match self { - WebsiteError::NotFound => { - info!("not found"); - (StatusCode::NOT_FOUND, ()).into_response() - } + WebsiteError::NotFound => (StatusCode::NOT_FOUND, ()).into_response(), WebsiteError::InternalError(e) => { if let Some(s) = e.source() { error!("internal error: {}: {}", e, s); @@ -148,7 +146,9 @@ mod tests { }; // Load the actual posts, just to make this test fail if // 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.pages = posts; let state = Arc::new(state); diff --git a/src/handlers/pages.rs b/src/handlers/pages.rs index 949ac31..8ecf0bf 100644 --- a/src/handlers/pages.rs +++ b/src/handlers/pages.rs @@ -2,17 +2,17 @@ use std::sync::Arc; use axum::{ extract::{Path, State}, - http::{header, HeaderMap, StatusCode}, + http::{self, header, HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, routing::get, Router, }; use serde_derive::Serialize; -use tracing::{instrument, log::warn}; +use tracing::instrument; use crate::{ - page::{render_post, Page}, + page::{render_page, Page}, AppState, WebsiteError, }; @@ -28,10 +28,39 @@ pub fn router() -> Router> { .route("/posts/:slug/index.md", get(super::not_found)) } -pub fn alias_router<'a>(posts: impl IntoIterator) -> Router> { +// pub fn pages_router<'a>(pages: impl IntoIterator) -> Router> { +// let mut router = Router::new(); + +// for post in pages { +// let slug = post.slug.clone(); +// router = router.route( +// &post.absolute_path, +// get( +// move |state: State>, +// 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) -> Router> { let mut router = Router::new(); - for post in posts { + for post in pages { for alias in &post.aliases { let path = post.absolute_path.clone(); router = router.route( @@ -82,10 +111,15 @@ async fn index( 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), ) .into_response()) @@ -95,29 +129,42 @@ async fn index( async fn view( Path(slug): Path, State(state): State>, + method: http::method::Method, headers: HeaderMap, ) -> Result { 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() { - warn!("attempted to view post before it has been published!"); 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(( StatusCode::OK, - [( - header::LAST_MODIFIED, - last_changed.map_or_else(|| state.startup_time.to_rfc2822(), |d| d.to_rfc2822()), - )], + [ + (header::ETAG, post.etag.as_str()), + (header::CACHE_CONTROL, "no-cache"), + ], Html(res), ) .into_response()) diff --git a/src/main.rs b/src/main.rs index ee3f58c..e123a58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,9 @@ use settings::Settings; use tag::Tag; use tera::Tera; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, signal}; use tower_http::{compression::CompressionLayer, cors::CorsLayer}; -use tracing::{instrument, log::info}; +use tracing::{debug, instrument, log::info}; use anyhow::{Error, Result}; @@ -44,7 +44,10 @@ async fn main() -> Result<()> { info!("Starting server..."); let app = init_app(&cfg).await?; 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(); @@ -63,9 +66,14 @@ async fn init_app(cfg: &Settings) -> Result { ..Default::default() }; - let posts = page::load_all(&state, "pages/".into()).await?; - let tags = tag::get_tags(posts.values()); - state.pages = posts; + let pages = page::load_all(&state, "posts/".into()).await?; + info!("{} pages loaded", pages.len()); + 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; let state = Arc::new(state); @@ -81,6 +89,30 @@ async fn init_app(cfg: &Settings) -> Result { .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)] pub enum WebsiteError { NotFound, diff --git a/src/markdown.rs b/src/markdown.rs index 3183955..ca76058 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -16,7 +16,7 @@ static EMAIL_RE: Lazy = Lazy::new(|| Regex::new(r"^.+?@\w+(\.\w+)*$").unw pub struct RenderResult { pub content_html: String, - pub metadata: String + pub metadata: String, } #[instrument(skip(markdown))] diff --git a/src/observability.rs b/src/observability.rs index c2c83ba..c97b6bf 100644 --- a/src/observability.rs +++ b/src/observability.rs @@ -9,7 +9,10 @@ use axum::{ use opentelemetry::{global, KeyValue}; use opentelemetry_otlp::WithExportConfig; 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_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -21,8 +24,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> { .with_default_directive("info".parse()?) .parse_lossy(&cfg.logging); - - if cfg.otlp.enabled { let tracer = opentelemetry_otlp::new_pipeline() .tracing() @@ -42,7 +43,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> { global::set_text_map_propagator(TraceContextPropagator::new()); let otel_tracer = tracing_opentelemetry::layer().with_tracer(tracer); - let meter = opentelemetry_otlp::new_pipeline() .metrics(opentelemetry_sdk::runtime::Tokio) .with_exporter( @@ -59,8 +59,6 @@ pub fn init(cfg: &Settings) -> Result<(), Error> { global::set_meter_provider(meter); - - // let logger = opentelemetry_otlp::new_pipeline() // .logging() // .with_exporter( @@ -76,17 +74,13 @@ pub fn init(cfg: &Settings) -> Result<(), Error> { .with(otel_tracer) .with(tracing_subscriber::fmt::layer().compact()) .init(); - } - else { + } else { tracing_subscriber::registry() .with(filter) .with(tracing_subscriber::fmt::layer().compact()) .init(); } - - - Ok(()) } diff --git a/src/page.rs b/src/page.rs index b830e6d..46ab34a 100644 --- a/src/page.rs +++ b/src/page.rs @@ -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; @@ -7,10 +12,7 @@ use chrono::{DateTime, FixedOffset}; use serde_derive::{Deserialize, Serialize}; use tokio::fs; -use tracing::{ - instrument, - log::debug, -}; +use tracing::{instrument, log::debug}; use crate::{helpers, markdown, AppState, WebsiteError}; @@ -35,13 +37,19 @@ pub struct Page { pub content: String, pub slug: String, pub absolute_path: String, + pub etag: String, } impl 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 { absolute_path: format!("/posts/{slug}/"), slug, + etag, content, title: fm.title, draft: fm.draft.unwrap_or(false), @@ -81,8 +89,7 @@ pub async fn load_all(state: &AppState, folder: PathBuf) -> Result Result Result { - debug!("loading post: {path:?}"); + debug!("loading page: {path:?}"); let content = fs::read_to_string(path).await?; - - let path_str = path.to_string_lossy().replace('\\', "/"); let slug = path_str - .trim_start_matches("posts/") + .trim_start_matches("posts") .trim_start_matches('/') .trim_end_matches(".html") .trim_end_matches(".md") .trim_end_matches("index") .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); @@ -132,10 +143,10 @@ pub async fn load_page(state: &AppState, path: &Path) -> Result { )) } -#[instrument(skip(state, post))] -pub async fn render_post(state: &AppState, post: &Page) -> Result { +#[instrument(skip(state, page))] +pub async fn render_page(state: &AppState, page: &Page) -> Result { let mut ctx = tera::Context::new(); - ctx.insert("page", &post); + ctx.insert("page", &page); ctx.insert("base_url", &state.base_url.to_string()); state @@ -160,7 +171,7 @@ mod tests { state.pages = super::load_all(&state, "posts/".into()).await.unwrap(); for post in state.pages.values() { - super::render_post(&state, post).await.unwrap(); + super::render_page(&state, post).await.unwrap(); } } }