diff --git a/src/cli/serve.rs b/src/cli/serve.rs index d62849f..b9c0e27 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use crate::server; @@ -9,12 +9,17 @@ pub fn serve(config: &crate::config::Config) -> u8 { tracing::info!("Initializing database and running migrations"); + // TODO remove unwraps let store = gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME)) .unwrap(); + let tera = crate::web::initialize_tera().unwrap(); let store = gpodder::GpodderRepository::new(store); - let ctx = server::Context { store }; + let ctx = server::Context { + store, + tera: Arc::new(tera), + }; let app = server::app(ctx.clone()); let rt = tokio::runtime::Builder::new_multi_thread() diff --git a/src/main.rs b/src/main.rs index b42d31a..fa680ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod config; mod server; +mod web; use clap::Parser; diff --git a/src/server/error.rs b/src/server/error.rs index dacc052..ca1da44 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -10,6 +10,7 @@ pub type AppResult = Result; pub enum AppError { // Db(db::DbError), IO(std::io::Error), + Tera(tera::Error), Other(Box), BadRequest, Unauthorized, @@ -21,6 +22,7 @@ impl fmt::Display for AppError { match self { // Self::Db(_) => write!(f, "database error"), Self::IO(_) => write!(f, "io error"), + Self::Tera(_) => write!(f, "tera error"), Self::Other(_) => write!(f, "other error"), Self::BadRequest => write!(f, "bad request"), Self::Unauthorized => write!(f, "unauthorized"), @@ -33,6 +35,7 @@ impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { // Self::Db(err) => Some(err), + Self::Tera(err) => Some(err), Self::IO(err) => Some(err), Self::Other(err) => Some(err.as_ref()), Self::NotFound | Self::Unauthorized | Self::BadRequest => None, @@ -46,6 +49,12 @@ impl From for AppError { } } +impl From for AppError { + fn from(value: tera::Error) -> Self { + Self::Tera(value) + } +} + impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { diff --git a/src/server/mod.rs b/src/server/mod.rs index 17ba893..f9ef26a 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,6 +1,9 @@ mod error; mod gpodder; mod r#static; +mod web; + +use std::sync::Arc; use axum::{ body::Body, @@ -16,12 +19,14 @@ use tower_http::trace::TraceLayer; #[derive(Clone)] pub struct Context { pub store: ::gpodder::GpodderRepository, + pub tera: Arc, } pub fn app(ctx: Context) -> Router { Router::new() .merge(gpodder::router(ctx.clone())) .nest("/static", r#static::router()) + .nest("/_", web::router(ctx.clone())) .layer(axum::middleware::from_fn(header_logger)) .layer(axum::middleware::from_fn(body_logger)) .layer(TraceLayer::new_for_http()) diff --git a/src/server/web/mod.rs b/src/server/web/mod.rs new file mode 100644 index 0000000..f3c3e5d --- /dev/null +++ b/src/server/web/mod.rs @@ -0,0 +1,13 @@ +use axum::{extract::State, http::HeaderMap, routing::get, Router}; + +use crate::web::{Page, TemplateExt, TemplateResponse, View}; + +use super::Context; + +pub fn router(_ctx: Context) -> Router { + Router::new().route("/", get(get_index)) +} + +async fn get_index(State(ctx): State, headers: HeaderMap) -> TemplateResponse> { + View::Index.page(&headers).response(&ctx.tera) +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..df87f93 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,78 @@ +mod page; +mod view; + +use std::sync::Arc; + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, + response::{Html, IntoResponse}, +}; + +pub use page::Page; +pub use view::View; + +const BASE_TEMPLATE: &str = "base.html"; + +/// Trait defining shared methods for working with typed Tera templates +pub trait Template { + /// Returns the name or path used to identify the template in the Tera struct + fn template(&self) -> &'static str; + + /// Render the template using the given Tera instance. + /// + /// Templates are expected to manage their own context requirements if needed. + fn render(&self, tera: &tera::Tera) -> tera::Result; +} + +/// Useful additional functions on sized Template implementors +pub trait TemplateExt: Sized + Template { + fn response(self, tera: &Arc) -> TemplateResponse { + TemplateResponse::new(tera, self) + } + + fn page(self, headers: &HeaderMap) -> Page { + Page::new(self).headers(headers) + } +} + +impl TemplateExt for T {} + +/// A specific instance of a template. This type can be used as a return type from Axum handlers. +pub struct TemplateResponse { + tera: Arc, + template: T, +} + +impl TemplateResponse { + pub fn new(tera: &Arc, template: T) -> Self { + Self { + tera: Arc::clone(&tera), + template, + } + } +} + +impl IntoResponse for TemplateResponse { + fn into_response(self) -> Response { + match self.template.render(&self.tera) { + Ok(s) => Html(s).into_response(), + Err(err) => { + tracing::error!("tera template failed: {err}"); + + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } +} + +pub fn initialize_tera() -> tera::Result { + let mut tera = tera::Tera::default(); + + tera.add_raw_templates([ + (BASE_TEMPLATE, include_str!("templates/base.html")), + (View::Index.template(), include_str!("templates/index.html")), + ])?; + + Ok(tera) +} diff --git a/src/web/page.rs b/src/web/page.rs new file mode 100644 index 0000000..0ba40c5 --- /dev/null +++ b/src/web/page.rs @@ -0,0 +1,53 @@ +use axum::http::{HeaderMap, HeaderValue}; + +use super::Template; + +const HX_REQUEST_HEADER: &str = "HX-Request"; +const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; + +/// Overarching template type that conditionally wraps its inner template with the base template if +/// required, as derived from the request headers +pub struct Page { + template: T, + wrap_with_base: bool, +} + +impl Template for Page { + fn template(&self) -> &'static str { + self.template.template() + } + + fn render(&self, tera: &tera::Tera) -> tera::Result { + let inner = self.template.render(tera)?; + + if self.wrap_with_base { + let mut ctx = tera::Context::new(); + ctx.insert("inner", &inner); + + tera.render(super::BASE_TEMPLATE, &ctx) + } else { + Ok(inner) + } + } +} + +impl Page { + pub fn new(template: T) -> Self { + Self { + template, + wrap_with_base: false, + } + } + + pub fn headers(mut self, headers: &HeaderMap) -> Self { + let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); + let is_hist_restore_req = headers + .get(HX_HISTORY_RESTORE_HEADER) + .map(|val| val == HeaderValue::from_static("true")) + .unwrap_or(false); + + self.wrap_with_base = !is_htmx_req || is_hist_restore_req; + + self + } +} diff --git a/src/web/templates/base.html b/src/web/templates/base.html new file mode 100644 index 0000000..1894e72 --- /dev/null +++ b/src/web/templates/base.html @@ -0,0 +1,24 @@ + + + + + + + + + + + +
+ +
+ {{ inner | safe }} +
+
+ + diff --git a/src/web/templates/index.html b/src/web/templates/index.html new file mode 100644 index 0000000..6fc5b55 --- /dev/null +++ b/src/web/templates/index.html @@ -0,0 +1,3 @@ +

Otter

+ +Otter is a self-hostable Gpodder implementation. diff --git a/src/web/view.rs b/src/web/view.rs new file mode 100644 index 0000000..da51c86 --- /dev/null +++ b/src/web/view.rs @@ -0,0 +1,17 @@ +use super::Template; + +pub enum View { + Index, +} + +impl Template for View { + fn template(&self) -> &'static str { + match self { + Self::Index => "index.html", + } + } + + fn render(&self, tera: &tera::Tera) -> tera::Result { + tera.render(self.template(), &tera::Context::new()) + } +}