diff --git a/docs/draft.md b/docs/draft.md index 9539fa5..aa65361 100644 --- a/docs/draft.md +++ b/docs/draft.md @@ -1,3 +1,5 @@ # OAuth2 spec https://datatracker.ietf.org/doc/html/rfc6749 + +https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template diff --git a/src/controllers/ui/authorize.rs b/src/controllers/ui/authorize.rs index 7454730..c1344ff 100644 --- a/src/controllers/ui/authorize.rs +++ b/src/controllers/ui/authorize.rs @@ -1,22 +1,20 @@ -use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension}; +use axum::{extract::State, response::IntoResponse, Extension}; use minijinja::context; -use crate::{server::AppState, services::session::TokenClaims}; - - +use crate::{renderer::TemplateRenderer, server::AppState, services::session::TokenClaims}; pub async fn authorize_form( State(app_state): State, - Extension(token_claims): Extension + Extension(token_claims): Extension, + Extension(renderer): Extension ) -> impl IntoResponse { // 1. Check if the app is already authorized // 2. Query the app details - - Html( - app_state.templating_env.get_template("pages/authorize.html").unwrap() - .render(context!()) - .unwrap() - ) + renderer + .render( + "pages/authorize", + context!() + ) } diff --git a/src/controllers/ui/home.rs b/src/controllers/ui/home.rs index 5d5c656..f44ce35 100644 --- a/src/controllers/ui/home.rs +++ b/src/controllers/ui/home.rs @@ -1,17 +1,16 @@ -use axum::{extract::State, response::{Html, IntoResponse}}; +use axum::{response::IntoResponse, Extension}; use axum_macros::debug_handler; use minijinja::context; -use crate::server::AppState; +use crate::renderer::TemplateRenderer; #[debug_handler] pub async fn home( - State(app_state): State + Extension(renderer): Extension ) -> impl IntoResponse { - Html( - app_state.templating_env.get_template("pages/home.html").unwrap() - .render(context!()) - .unwrap() + renderer.render( + "pages/home", + context!() ) } diff --git a/src/controllers/ui/login.rs b/src/controllers/ui/login.rs index 8700267..9653080 100644 --- a/src/controllers/ui/login.rs +++ b/src/controllers/ui/login.rs @@ -1,23 +1,20 @@ use chrono::{Duration, SecondsFormat, Utc}; use log::info; -use serde::{Deserialize, Serialize}; -use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form}; +use serde::Deserialize; +use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, Extension, Form}; use fully_pub::fully_pub; use minijinja::context; use crate::{ - models::user::{User, UserStatus}, - server::AppState, - services::{password::verify_password_hash, session::create_token} + models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token} }; pub async fn login_form( - State(app_state): State + Extension(renderer): Extension ) -> impl IntoResponse { - Html( - app_state.templating_env.get_template("pages/login.html").unwrap() - .render(context!()) - .unwrap() + renderer.render( + "pages/login", + context!() ) } @@ -33,6 +30,7 @@ const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7k pub async fn perform_login( State(app_state): State, + Extension(renderer): Extension, Form(login): Form ) -> impl IntoResponse { // get user from db @@ -50,29 +48,25 @@ pub async fn perform_login( Err(_e) => DUMMY_PASSWORD_HASH.into() }; - let templ = app_state.templating_env.get_template("pages/login.html").unwrap(); - if verify_password_hash(password_hash, login.password).is_err() { - return ( + return renderer.render_with_status( StatusCode::BAD_REQUEST, - Html( - templ.render(context!( - error => Some("Invalid login or password.".to_string()) - )).unwrap() + "pages/login", + context!( + error => Some("Invalid login or password.".to_string()) ) - ).into_response(); + ); } let user = user_res.expect("Expected User to be found."); if user.status == UserStatus::Disabled { - return ( - StatusCode::BAD_REQUEST, - Html( - templ.render(context!( + return renderer.render_with_status( + StatusCode::BAD_REQUEST, + "pages/login", + context!( error => Some("This account is disabled.".to_string()) - )).unwrap() - ) - ).into_response(); + ) + ); } info!("User {:?} {:?} logged in", &user.handle, &user.email); @@ -87,13 +81,16 @@ pub async fn perform_login( // TODO: handle keep_session boolean from form and specify cookie max age only if this setting // is true let cookie_max_age = Duration::days(7).num_seconds(); + // enforce SameSite=Lax to avoid CSRF let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}"); let mut headers = HeaderMap::new(); headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap()); headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap()); - (StatusCode::FOUND, headers, Html( - templ.render(context!()).unwrap() - )).into_response() + renderer.render_with_status( + StatusCode::FOUND, + "pages/login", + context!() + ) } diff --git a/src/controllers/ui/me.rs b/src/controllers/ui/me.rs index b649e9b..d420ae4 100644 --- a/src/controllers/ui/me.rs +++ b/src/controllers/ui/me.rs @@ -1,13 +1,13 @@ use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; -use base64::prelude::{Engine, BASE64_STANDARD}; use fully_pub::fully_pub; use minijinja::context; -use crate::{models::user::User, server::AppState, services::session::TokenClaims}; +use crate::{models::user::User, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims}; pub async fn me_page( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension ) -> impl IntoResponse { @@ -17,19 +17,18 @@ pub async fn me_page( .await .expect("To get user from claim"); - Html( - app_state.templating_env.get_template("pages/me/index.html").unwrap() - .render(context!( - user => user_res, - user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x)) - )) - .unwrap() + renderer.render( + "pages/me/index", + context!( + user => user_res + ) ) } pub async fn me_update_details_form( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension ) -> impl IntoResponse { @@ -39,12 +38,11 @@ pub async fn me_update_details_form( .await .expect("To get user from claim"); - Html( - app_state.templating_env.get_template("pages/me/details-form.html").unwrap() - .render(context!( - user => user_res - )) - .unwrap() + renderer.render( + "pages/me/details-form", + context!( + user => user_res + ) ) } @@ -64,10 +62,11 @@ struct UserDetailsUpdateForm { pub async fn me_perform_update_details( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension, TypedMultipart(details_update): TypedMultipart ) -> impl IntoResponse { - let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap(); + let template_path = "pages/me/details-form"; let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1") .bind(&token_claims.sub) @@ -79,8 +78,6 @@ pub async fn me_perform_update_details( .execute(&app_state.db) .await; - dbg!(&update_res); - let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(&token_claims.sub) .fetch_one(&app_state.db) @@ -89,28 +86,23 @@ pub async fn me_perform_update_details( match update_res { Ok(_) => { - ( - StatusCode::OK, - Html( - template.render(context!( - success => true, - user => user_res - )) - .unwrap() + renderer.render( + template_path, + context!( + success => true, + user => user_res ) - ).into_response() + ) }, Err(err) => { dbg!(&err); - ( - StatusCode::BAD_REQUEST, - Html( - template.render(context!( - error => Some("Cannot update user details".to_string()), - user => user_res - )).unwrap() + renderer.render( + template_path, + context!( + error => Some("Cannot update user details".to_string()), + user => user_res ) - ).into_response() + ) } } diff --git a/src/main.rs b/src/main.rs index 53c9c00..11cd4ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ pub mod cli; pub mod utils; pub mod services; pub mod middlewares; +pub mod renderer; use std::{env, fs}; use anyhow::{Result, Context, anyhow}; diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 0e4a05d..25e5df7 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod renderer; diff --git a/src/middlewares/renderer.rs b/src/middlewares/renderer.rs new file mode 100644 index 0000000..ff2526f --- /dev/null +++ b/src/middlewares/renderer.rs @@ -0,0 +1,17 @@ +use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension}; + +use crate::{renderer::TemplateRenderer, server::AppState, services::session::{verify_token, TokenClaims}}; + +pub async fn renderer_middleware( + State(app_state): State, + token_claims_ext: Option>, + mut req: Request, + next: Next, +) -> Result { + let renderer_instance = TemplateRenderer { + env: app_state.templating_env, + token_claims: token_claims_ext.map(|x| x.0) + }; + req.extensions_mut().insert(renderer_instance); + Ok(next.run(req).await) +} diff --git a/src/models/user.rs b/src/models/user.rs index bfcbde6..10736f8 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -27,4 +27,3 @@ struct User { last_login_at: Option>, created_at: DateTime } - diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..5ebdec3 --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,60 @@ +use axum::{extract::FromRef, http::{Response, StatusCode}, response::{Html, IntoResponse}, Extension}; +use fully_pub::fully_pub; +use log::error; +use minijinja::{context, Environment, Value}; + +use crate::{server::AppState, services::session::TokenClaims}; + + +#[derive(Clone)] +#[fully_pub] +struct TemplateRenderer { + env: Environment<'static>, + token_claims: Option +} + +impl TemplateRenderer { + pub(crate) fn from_env( + env: Environment<'static>, + ) -> Self { + Self { + env, + token_claims: None + } + } + + pub(crate) fn from_token_claims( + app_state: AppState, + token_claims: TokenClaims + ) -> Self { + Self { + env: app_state.templating_env, + token_claims: Some(token_claims) + } + } + + pub(crate) fn render(&self, name: &str, ctx: Value) -> impl IntoResponse { + match self + .env + .get_template(&format!("{name}.html")) + .and_then(|tmpl| tmpl.render(context! { token_claims => self.token_claims, ..ctx })) + { + Ok(content) => Html(content).into_response(), + Err(err) => { + dbg!(err); + error!("FATAL: Failed to render template {}", name); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } + } + } + + pub(crate) fn render_with_status(&self, status: StatusCode, name: &str, ctx: Value) -> impl IntoResponse { + let mut res = self.render(name, ctx).into_response(); + if res.status().is_server_error() { + return res + } + *res.status_mut() = status; + return res; + } +} + diff --git a/src/router.rs b/src/router.rs index cb3be72..eecbbc1 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,7 +1,7 @@ use axum::{middleware, routing::{get, post}, Router}; use tower_http::services::ServeDir; -use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}}; +use crate::{controllers::ui, middlewares::{auth::auth_middleware, renderer::renderer_middleware}, server::{AppState, ServerConfig}}; pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router { let public_routes = Router::new() @@ -17,11 +17,12 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router .route("/me/details-form", post(ui::me::me_perform_update_details)) .route("/logout", get(ui::logout::perform_logout)) .route("/authorize", get(ui::authorize::authorize_form)) - .layer(middleware::from_fn_with_state(app_state, auth_middleware)); + .layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware)); Router::new() .merge(public_routes) .merge(user_routes) + .layer(middleware::from_fn_with_state(app_state, renderer_middleware)) .nest_service( "/assets", ServeDir::new(server_config.assets_path.clone()) diff --git a/src/server.rs b/src/server.rs index 3c5a7f9..6833988 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; use fully_pub::fully_pub; use anyhow::{Result, Context}; use log::info; @@ -13,6 +14,9 @@ fn build_templating_env(config: &Config) -> Environment<'static> { env.add_global("gl", context! { instance => config.instance }); + env.add_function("encode_b64str", |bin_val: Vec| { + BASE64_STANDARD.encode(bin_val) + }); env } diff --git a/src/templates/components/header.html b/src/templates/components/header.html index 794483c..2e8be44 100644 --- a/src/templates/components/header.html +++ b/src/templates/components/header.html @@ -5,23 +5,26 @@ diff --git a/src/templates/pages/authorize.html b/src/templates/pages/authorize.html index 95f1129..7003d68 100644 --- a/src/templates/pages/authorize.html +++ b/src/templates/pages/authorize.html @@ -7,8 +7,14 @@ {% endif %}
- - +

Do you authorize this app?

+
    +
  • App name:
  • +
  • Permisions: read basics
  • +
+ + +
diff --git a/src/templates/pages/me/index.html b/src/templates/pages/me/index.html index 97c5d4f..3ab2ee7 100644 --- a/src/templates/pages/me/index.html +++ b/src/templates/pages/me/index.html @@ -5,8 +5,8 @@ Update details

-{% if user_picture %} - +{% if user.picture %} + {% endif %}

  • diff --git a/src/templates/pages/register.html b/src/templates/pages/register.html index b9f2881..8701dc3 100644 --- a/src/templates/pages/register.html +++ b/src/templates/pages/register.html @@ -24,7 +24,7 @@ class="form-control" /> -
    +
    -
    +