regalade/src/app/mod.rs
2024-12-30 22:30:27 +01:00

390 lines
11 KiB
Rust

use std::sync::Arc;
use axum::{
async_trait,
extract::{FromRef, FromRequestParts, Query, State},
handler::HandlerWithoutStateExt,
http::{request::Parts, StatusCode},
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use maud::{html, Markup};
use sea_orm::{prelude::*, ActiveValue, DbErr, TransactionError};
use serde::Deserialize;
use tower_http::services::ServeDir;
use tower_sessions::{session, Session};
use uuid::Uuid;
use crate::entity::{prelude::*, user};
use self::{household::CurrentHousehold, sidebar::SidebarLocation};
mod household;
mod ingredients;
mod recipe;
mod sidebar;
type AppState = Arc<crate::AppState>;
pub fn base_page_with_head(body: Markup, head: Option<Markup>) -> Markup {
html! {
(maud::DOCTYPE)
html lang="en" data-bs-theme="dark" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Regalade" }
link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous";
link rel="stylesheet" href="/regalade.css";
link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+"
crossorigin="anonymous";
@if let Some(head) = head {
(head)
}
}
body {
(body)
script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous" {}
script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous" {}
}
}
}
}
pub fn base_page(body: Markup) -> Markup {
base_page_with_head(body, None)
}
pub fn error_alert(message: Option<impl maud::Render>) -> Markup {
html! {
@if let Some(msg) = message {
.alert.alert-danger { (msg) }
}
}
}
// pub fn base_page_with_error(error: Option<Markup>, body: Markup) -> Markup {
// base_page_with_head(
// html! {
// @if let Some(e) = error {
// (e)
// }
// (body)
// },
// None,
// )
// }
pub fn error_page(code: StatusCode, message: impl maud::Render) -> (StatusCode, Markup) {
(
code,
base_page(html! {
div .container.text-center {
h1 { "Regalade" }
h2 { "Error" }
hr;
(message)
}
}),
)
}
#[derive(thiserror::Error, Debug)]
enum RouteError {
#[error("Database encountered an error")]
Db(#[from] DbErr),
#[error("Failure to login with OAuth2")]
Oauth2Failure,
#[error("Could not fetch required value from path")]
PathRejection(#[from] axum::extract::rejection::PathRejection),
#[error("The supplied ressource does not exist")]
RessourceNotFound,
#[error("The request was malformed")]
InvalidRequest(String),
#[error("Error in DB transaction")]
TxnError(#[from] TransactionError<Box<RouteError>>),
#[error("Error in session management")]
Session(#[from] session::Error),
#[error("Could not extract session")]
SessionExtract,
#[error("Unexpected internal error")]
Internal(String),
}
impl From<DbErr> for Box<RouteError> {
fn from(value: DbErr) -> Self {
Box::new(value.into())
}
}
impl From<TransactionError<RouteError>> for RouteError {
fn from(value: TransactionError<RouteError>) -> Self {
match value {
TransactionError::Connection(e) => e.into(),
TransactionError::Transaction(e) => e,
}
}
}
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
match self {
RouteError::TxnError(TransactionError::Transaction(e)) => e.into_response(),
RouteError::PathRejection(p) => error_page(p.status(), p.body_text()).into_response(),
RouteError::Oauth2Failure => {
error_page(StatusCode::BAD_REQUEST, "Failure to login with OAuth2").into_response()
}
RouteError::RessourceNotFound => not_found().into_response(),
RouteError::InvalidRequest(reason) => {
error_page(StatusCode::BAD_REQUEST, reason).into_response()
}
e => {
tracing::error!("Internal error: {e:?}");
error_page(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
}
}
}
}
#[axum::debug_handler]
async fn oidc_login(State(state): State<AppState>, jar: CookieJar) -> (CookieJar, Redirect) {
tracing::info!("Starting OIDC login");
let oidc = state.oidc.as_ref().unwrap();
let (flow_id, redirect_url) = oidc.start_auth();
let jar = jar.add(
Cookie::build(("login_flow_id", flow_id.to_string()))
.secure(true)
.same_site(SameSite::Lax)
.build(),
);
(jar, Redirect::to(redirect_url.as_str()))
}
enum RedirectOrError {
Redirect(Redirect),
Err(RouteError),
}
impl From<Redirect> for RedirectOrError {
fn from(value: Redirect) -> Self {
Self::Redirect(value)
}
}
impl From<RouteError> for RedirectOrError {
fn from(value: RouteError) -> Self {
Self::Err(value)
}
}
impl IntoResponse for RedirectOrError {
fn into_response(self) -> axum::response::Response {
match self {
RedirectOrError::Redirect(r) => r.into_response(),
RedirectOrError::Err(e) => e.into_response(),
}
}
}
struct AuthenticatedUser {
pub model: user::Model,
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = RedirectOrError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let State(app_state): State<AppState> = State::from_request_parts(parts, state)
.await
.expect("Could not get state");
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| RouteError::SessionExtract)?;
let id: Uuid = session
.get("id")
.await
.map_err(RouteError::from)?
.ok_or_else(|| Redirect::to("/login"))?;
let model = User::find_by_id(id)
.one(&app_state.db)
.await
.map_err(RouteError::from)?
.unwrap();
Ok(Self { model })
}
}
#[derive(Deserialize)]
struct OidcRedirectParams {
state: String,
code: String,
}
async fn oidc_login_finish(
State(state): State<AppState>,
Query(redirect): Query<OidcRedirectParams>,
session: Session,
jar: CookieJar,
) -> Result<Redirect, RouteError> {
let Some(Ok(id)) = jar.get("login_flow_id").map(|c| c.value().parse()) else {
return Err(RouteError::Oauth2Failure);
};
match state
.oidc
.as_ref()
.unwrap()
.redirected(id, redirect.state, redirect.code)
.await
{
Err(e) => {
tracing::error!("Error when finishing OAuth2 flow: {e:#?}");
Err(RouteError::Oauth2Failure)
}
Ok(account) => {
let user = User::find()
.filter(
user::Column::Name
.eq(&account.name)
.or(user::Column::OpenIdSubject.eq(&account.sub)),
)
.one(&state.db)
.await?;
let user = match user {
None => {
let model = user::ActiveModel {
id: ActiveValue::Set(Uuid::new_v4()),
name: ActiveValue::Set(account.name),
password: ActiveValue::NotSet,
open_id_subject: ActiveValue::Set(Some(account.sub)),
};
model.insert(&state.db).await?
}
Some(user) => user,
};
session.insert("id", user.id).await?;
Ok(Redirect::to("/"))
}
}
}
fn not_found() -> (StatusCode, Markup) {
error_page(StatusCode::NOT_FOUND, "Page not found")
}
fn confirm_danger_modal(id: &str, inner: &str, action: &str, title: &str) -> Markup {
html! {
.modal
#(id)
tabindex="-1"
aria-labelledby={(id) "Label"}
aria-hidden="true" {
.modal-dialog.modal-dialog-centered {
.modal-content {
.modal-header {
h1 .modal-title."fs-5" #{(id) "Label"} { (title) }
button
.btn-close
data-bs-dismiss="modal"
aria-label="Cancel" {}
}
.modal-body {
(inner)
}
.modal-footer {
button .btn.btn-secondary data-bs-dismiss="modal" { "Cancel" }
form action=(action) method="post" .inline {
button type="submit" .btn.btn-danger { "Confirm" }
}
}
}
}
}
}
}
async fn index(user: AuthenticatedUser, household: CurrentHousehold) -> Markup {
sidebar::sidebar(
SidebarLocation::Home,
&household,
&user,
html! {
"Hello world in " (household.0.name) "!"
},
)
}
async fn logout(session: Session) -> Result<Redirect, RouteError> {
session.delete().await?;
Ok(Redirect::to("/login"))
}
// #[derive(Serialize, Deserialize, Debug)]
// enum UserError {
// }
//
// #[derive(Debug, Serialize, Deserialize)]
// struct ErrorQuery {
// #[serde(default)]
// err: Option<UserError>,
// }
//
// impl Render for ErrorQuery {
// fn render(&self) -> Markup {
// let err = match &self.err {
// Some(e) => e,
// None => return html! {},
// };
//
// match err {
// _ => todo!(),
// }
// }
// }
pub(crate) fn router() -> Router<AppState> {
let router = Router::new();
let public = option_env!("REGALADE_PUBLIC_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("public"));
tracing::debug!("Public directory: {public:?}");
router
.route("/", get(index))
.route("/login", get(oidc_login))
.route("/logout", get(logout))
.route("/login/redirect", get(oidc_login_finish))
.nest("/household", household::routes())
.nest("/recipe", recipe::routes())
.nest("/ingredients", ingredients::routes())
.fallback_service(ServeDir::new(public).fallback((|| async { not_found() }).into_service()))
}