diff --git a/rust/.sqlx/query-3abc853a3a44c7ecb411f4374bcd05c87cd811a0f0d967d7be4bda908d8d45a1.json b/rust/.sqlx/query-3abc853a3a44c7ecb411f4374bcd05c87cd811a0f0d967d7be4bda908d8d45a1.json new file mode 100644 index 0000000..1a8d134 --- /dev/null +++ b/rust/.sqlx/query-3abc853a3a44c7ecb411f4374bcd05c87cd811a0f0d967d7be4bda908d8d45a1.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id,username,fullname FROM users WHERE username = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "fullname", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3abc853a3a44c7ecb411f4374bcd05c87cd811a0f0d967d7be4bda908d8d45a1" +} diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 47e719e..3c8343e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,6 +3,16 @@ name = "packager" version = "0.1.0" edition = "2021" +default-run = "packager" + +[[bin]] +name = "packager" +path = "src/main.rs" + +[[bin]] +name = "packager-adm" +path = "src/bin/adm.rs" + [profile.dev] opt-level = 0 lto = "off" diff --git a/rust/migrations/20230819125455_add_user.sql b/rust/migrations/20230819125455_add_user.sql new file mode 100644 index 0000000..7816703 --- /dev/null +++ b/rust/migrations/20230819125455_add_user.sql @@ -0,0 +1,6 @@ +CREATE TABLE "users" ( + id VARCHAR(36) NOT NULL, + fullname TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, + PRIMARY KEY (id) +); diff --git a/rust/src/bin/adm.rs b/rust/src/bin/adm.rs new file mode 100644 index 0000000..3de6465 --- /dev/null +++ b/rust/src/bin/adm.rs @@ -0,0 +1,8 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[arg(long)] + database_url: String, +} diff --git a/rust/src/error.rs b/rust/src/error.rs index 61dabd3..f43d13b 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -13,6 +13,9 @@ pub enum RequestError { RefererNotFound, RefererInvalid { message: String }, NotFound { message: String }, + AuthenticationUserNotFound { username: String }, + AuthenticationHeaderMissing, + AuthenticationHeaderInvalid { message: String }, } impl fmt::Display for RequestError { @@ -22,6 +25,13 @@ impl fmt::Display for RequestError { Self::RefererNotFound => write!(f, "Referer header not found"), Self::RefererInvalid { message } => write!(f, "Referer header invalid: {message}"), Self::NotFound { message } => write!(f, "Not found: {message}"), + Self::AuthenticationUserNotFound { username } => { + write!(f, "User \"{username}\" not found") + } + Self::AuthenticationHeaderMissing => write!(f, "Authentication header not found"), + Self::AuthenticationHeaderInvalid { message } => { + write!(f, "Authentication header invalid: {message}") + } } } } @@ -103,6 +113,18 @@ impl IntoResponse for Error { StatusCode::NOT_FOUND, view::ErrorPage::build(&format!("not found: {}", message)), ), + RequestError::AuthenticationUserNotFound { username: _ } => ( + StatusCode::BAD_REQUEST, + view::ErrorPage::build(&request_error.to_string()), + ), + RequestError::AuthenticationHeaderMissing => ( + StatusCode::UNAUTHORIZED, + view::ErrorPage::build(&request_error.to_string()), + ), + RequestError::AuthenticationHeaderInvalid { message: _ } => ( + StatusCode::UNAUTHORIZED, + view::ErrorPage::build(&request_error.to_string()), + ), }, } .into_response() diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..3139801 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,5 @@ +mod error; +mod models; +mod sqlite; + +use error::{Error, RequestError, StartError}; diff --git a/rust/src/main.rs b/rust/src/main.rs index cfdbf29..f2c46d3 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,11 +1,14 @@ use axum::{ - extract::{Path, Query, State}, + extract::{Extension, Path, Query, State}, http::header::{self, HeaderMap, HeaderName, HeaderValue}, + middleware::{self, Next}, response::{IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; +use hyper::Request; + use serde::Deserialize; use uuid::Uuid; @@ -14,18 +17,35 @@ use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; -mod error; mod html; -mod models; -mod sqlite; mod view; +type User = models::user::User; + use error::{Error, RequestError, StartError}; +#[derive(Clone)] +pub enum AuthConfig { + Enabled, + Disabled { assume_user: String }, +} + #[derive(Clone)] pub struct AppState { database_pool: sqlite::Pool, client_state: ClientState, + auth_config: AuthConfig, +} + +#[derive(Clone)] +pub struct Context { + user: User, +} + +impl Context { + fn build(user: User) -> Self { + Self { user } + } } use clap::Parser; @@ -39,6 +59,8 @@ struct Args { port: u16, #[arg(long)] bind: String, + #[arg(long, name = "USERNAME")] + disable_auth_and_assume_user: Option, } #[derive(Clone)] @@ -156,6 +178,51 @@ impl From for HeaderName { } } +async fn authorize( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let current_user = match state.auth_config { + AuthConfig::Disabled { assume_user } => { + match models::user::User::find_by_name(&state.database_pool, &assume_user).await? { + Some(user) => user, + None => { + return Err(Error::Request(RequestError::AuthenticationUserNotFound { + username: assume_user, + })) + } + } + } + AuthConfig::Enabled => { + let Some(username) = request.headers().get("x-auth-username") else { + return Err(Error::Request(RequestError::AuthenticationHeaderMissing)); + }; + + let username = username + .to_str() + .map_err(|error| { + Error::Request(RequestError::AuthenticationHeaderInvalid { + message: error.to_string(), + }) + })? + .to_string(); + + match models::user::User::find_by_name(&state.database_pool, &username).await? { + Some(user) => user, + None => { + return Err(Error::Request(RequestError::AuthenticationUserNotFound { + username, + })) + } + } + } + }; + + request.extensions_mut().insert(current_user); + Ok(next.run(request).await) +} + #[tokio::main] async fn main() -> Result<(), StartError> { tracing_subscriber::fmt() @@ -164,12 +231,17 @@ async fn main() -> Result<(), StartError> { let args = Args::parse(); - let database_pool = sqlite::init_database_pool(&args.database_url).await?; + let database_pool = sqlie::init_database_pool(&args.database_url).await?; sqlite::migrate(&database_pool).await?; let state = AppState { database_pool, client_state: ClientState::new(), + auth_config: if let Some(assume_user) = args.disable_auth_and_assume_user { + AuthConfig::Disabled { assume_user } + } else { + AuthConfig::Enabled + }, }; let icon_handler = || async { @@ -183,7 +255,6 @@ async fn main() -> Result<(), StartError> { let app = Router::new() .route("/favicon.svg", get(icon_handler)) .route("/assets/luggage.svg", get(icon_handler)) - .route("/", get(root)) .route( "/notfound", get(|| async { @@ -193,75 +264,81 @@ async fn main() -> Result<(), StartError> { }), ) .route("/debug", get(debug)) - .nest( - (&TopLevelPage::Trips.path()).into(), + .merge( + // thse are routes that require authentication Router::new() - .route("/", get(trips).post(trip_create)) - .route("/types/", get(trips_types).post(trip_type_create)) - .route("/types/:id/edit/name/submit", post(trips_types_edit_name)) - .route("/:id/", get(trip)) - .route("/:id/comment/submit", post(trip_comment_set)) - .route("/:id/categories/:id/select", post(trip_category_select)) - .route("/:id/packagelist/", get(trip_packagelist)) - .route( - "/:id/packagelist/item/:id/pack", - post(trip_item_packagelist_set_pack_htmx), + .route("/", get(root)) + .nest( + (&TopLevelPage::Trips.path()).into(), + Router::new() + .route("/", get(trips).post(trip_create)) + .route("/types/", get(trips_types).post(trip_type_create)) + .route("/types/:id/edit/name/submit", post(trips_types_edit_name)) + .route("/:id/", get(trip)) + .route("/:id/comment/submit", post(trip_comment_set)) + .route("/:id/categories/:id/select", post(trip_category_select)) + .route("/:id/packagelist/", get(trip_packagelist)) + .route( + "/:id/packagelist/item/:id/pack", + post(trip_item_packagelist_set_pack_htmx), + ) + .route( + "/:id/packagelist/item/:id/unpack", + post(trip_item_packagelist_set_unpack_htmx), + ) + .route( + "/:id/packagelist/item/:id/ready", + post(trip_item_packagelist_set_ready_htmx), + ) + .route( + "/:id/packagelist/item/:id/unready", + post(trip_item_packagelist_set_unready_htmx), + ) + .route("/:id/state/:id", post(trip_state_set)) + .route("/:id/total_weight", get(trip_total_weight_htmx)) + .route("/:id/type/:id/add", get(trip_type_add)) + .route("/:id/type/:id/remove", get(trip_type_remove)) + .route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) + .route( + "/:id/items/:id/pick", + get(trip_item_set_pick).post(trip_item_set_pick_htmx), + ) + .route( + "/:id/items/:id/unpick", + get(trip_item_set_unpick).post(trip_item_set_unpick_htmx), + ) + .route( + "/:id/items/:id/pack", + get(trip_item_set_pack).post(trip_item_set_pack_htmx), + ) + .route( + "/:id/items/:id/unpack", + get(trip_item_set_unpack).post(trip_item_set_unpack_htmx), + ) + .route( + "/:id/items/:id/ready", + get(trip_item_set_ready).post(trip_item_set_ready_htmx), + ) + .route( + "/:id/items/:id/unready", + get(trip_item_set_unready).post(trip_item_set_unready_htmx), + ), ) - .route( - "/:id/packagelist/item/:id/unpack", - post(trip_item_packagelist_set_unpack_htmx), + .nest( + (&TopLevelPage::Inventory.path()).into(), + Router::new() + .route("/", get(inventory_inactive)) + .route("/categories/:id/select", post(inventory_category_select)) + .route("/category/", post(inventory_category_create)) + .route("/category/:id/", get(inventory_active)) + .route("/item/", post(inventory_item_create)) + .route("/item/:id/", get(inventory_item)) + .route("/item/:id/cancel", get(inventory_item_cancel)) + .route("/item/:id/delete", get(inventory_item_delete)) + .route("/item/:id/edit", post(inventory_item_edit)) + .route("/item/name/validate", post(inventory_item_validate_name)), ) - .route( - "/:id/packagelist/item/:id/ready", - post(trip_item_packagelist_set_ready_htmx), - ) - .route( - "/:id/packagelist/item/:id/unready", - post(trip_item_packagelist_set_unready_htmx), - ) - .route("/:id/state/:id", post(trip_state_set)) - .route("/:id/total_weight", get(trip_total_weight_htmx)) - .route("/:id/type/:id/add", get(trip_type_add)) - .route("/:id/type/:id/remove", get(trip_type_remove)) - .route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) - .route( - "/:id/items/:id/pick", - get(trip_item_set_pick).post(trip_item_set_pick_htmx), - ) - .route( - "/:id/items/:id/unpick", - get(trip_item_set_unpick).post(trip_item_set_unpick_htmx), - ) - .route( - "/:id/items/:id/pack", - get(trip_item_set_pack).post(trip_item_set_pack_htmx), - ) - .route( - "/:id/items/:id/unpack", - get(trip_item_set_unpack).post(trip_item_set_unpack_htmx), - ) - .route( - "/:id/items/:id/ready", - get(trip_item_set_ready).post(trip_item_set_ready_htmx), - ) - .route( - "/:id/items/:id/unready", - get(trip_item_set_unready).post(trip_item_set_unready_htmx), - ), - ) - .nest( - (&TopLevelPage::Inventory.path()).into(), - Router::new() - .route("/", get(inventory_inactive)) - .route("/categories/:id/select", post(inventory_category_select)) - .route("/category/", post(inventory_category_create)) - .route("/category/:id/", get(inventory_active)) - .route("/item/", post(inventory_item_create)) - .route("/item/:id/", get(inventory_item)) - .route("/item/:id/cancel", get(inventory_item_cancel)) - .route("/item/:id/delete", get(inventory_item_delete)) - .route("/item/:id/edit", post(inventory_item_edit)) - .route("/item/name/validate", post(inventory_item_validate_name)), + .layer(middleware::from_fn_with_state(state.clone(), authorize)), ) .fallback(|| async { Error::Request(RequestError::NotFound { @@ -287,8 +364,12 @@ async fn main() -> Result<(), StartError> { Ok(()) } -async fn root() -> impl IntoResponse { - view::Root::build(&view::home::Home::build(), None) +async fn root(Extension(current_user): Extension) -> impl IntoResponse { + view::Root::build( + &Context::build(current_user), + &view::home::Home::build(), + None, + ) } async fn debug(headers: HeaderMap) -> impl IntoResponse { @@ -308,6 +389,7 @@ struct InventoryQuery { } async fn inventory_active( + Extension(current_user): Extension, State(mut state): State, Path(id): Path, Query(inventory_query): Query, @@ -332,6 +414,7 @@ async fn inventory_active( .transpose()?; Ok(view::Root::build( + &Context::build(current_user), &view::inventory::Inventory::build( active_category, &inventory.categories, @@ -342,6 +425,7 @@ async fn inventory_active( } async fn inventory_inactive( + Extension(current_user): Extension, State(mut state): State, Query(inventory_query): Query, ) -> Result { @@ -351,6 +435,7 @@ async fn inventory_inactive( let inventory = models::inventory::Inventory::load(&state.database_pool).await?; Ok(view::Root::build( + &Context::build(current_user), &view::inventory::Inventory::build( None, &inventory.categories, @@ -543,10 +628,14 @@ async fn trip_create( Ok(Redirect::to(&format!("/trips/{new_id}/"))) } -async fn trips(State(state): State) -> Result { +async fn trips( + Extension(current_user): Extension, + State(state): State, +) -> Result { let trips = models::trips::Trip::all(&state.database_pool).await?; Ok(view::Root::build( + &Context::build(current_user), &view::trip::TripManager::build(trips), Some(&TopLevelPage::Trips), )) @@ -559,6 +648,7 @@ struct TripQuery { } async fn trip( + Extension(current_user): Extension, State(mut state): State, Path(id): Path, Query(trip_query): Query, @@ -593,6 +683,7 @@ async fn trip( .transpose()?; Ok(view::Root::build( + &Context::build(current_user), &view::trip::Trip::build( &trip, state.client_state.trip_edit_attribute, @@ -1027,6 +1118,7 @@ struct TripTypeQuery { } async fn trips_types( + Extension(current_user): Extension, State(mut state): State, Query(trip_type_query): Query, ) -> Result { @@ -1036,6 +1128,7 @@ async fn trips_types( models::trips::TripsType::all(&state.database_pool).await?; Ok(view::Root::build( + &Context::build(current_user), &view::trip::types::TypeList::build(&state.client_state, trip_types), Some(&TopLevelPage::Trips), )) @@ -1096,6 +1189,7 @@ async fn trips_types_edit_name( } async fn inventory_item( + Extension(current_user): Extension, State(state): State, Path(id): Path, ) -> Result { @@ -1106,6 +1200,7 @@ async fn inventory_item( }))?; Ok(view::Root::build( + &Context::build(current_user), &view::inventory::InventoryItem::build(&state.client_state, &item), Some(&TopLevelPage::Inventory), )) @@ -1178,6 +1273,7 @@ async fn inventory_category_select( } async fn trip_packagelist( + Extension(current_user): Extension, State(state): State, Path(trip_id): Path, ) -> Result { @@ -1190,6 +1286,7 @@ async fn trip_packagelist( trip.load_categories(&state.database_pool).await?; Ok(view::Root::build( + &Context::build(current_user), &view::trip::packagelist::TripPackageList::build(&trip), Some(&TopLevelPage::Trips), )) diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs index b4fd9b6..9352daf 100644 --- a/rust/src/models/mod.rs +++ b/rust/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod inventory; pub mod trips; +pub mod user; mod error; pub use error::{DatabaseError, Error, QueryError}; diff --git a/rust/src/models/user.rs b/rust/src/models/user.rs new file mode 100644 index 0000000..e6d73dc --- /dev/null +++ b/rust/src/models/user.rs @@ -0,0 +1,45 @@ +use super::Error; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct User { + pub id: Uuid, + pub username: String, + pub fullname: String, +} + +#[derive(Debug)] +pub struct DbUserRow { + id: String, + username: String, + fullname: String, +} + +impl TryFrom for User { + type Error = Error; + + fn try_from(row: DbUserRow) -> Result { + Ok(User { + id: Uuid::try_parse(&row.id)?, + username: row.username, + fullname: row.fullname, + }) + } +} + +impl User { + pub async fn find_by_name( + pool: &sqlx::Pool, + name: &str, + ) -> Result, Error> { + sqlx::query_as!( + DbUserRow, + "SELECT id,username,fullname FROM users WHERE username = ?", + name + ) + .fetch_optional(pool) + .await? + .map(|row: DbUserRow| row.try_into()) + .transpose() + } +} diff --git a/rust/src/view/mod.rs b/rust/src/view/mod.rs index 1030996..12afc4c 100644 --- a/rust/src/view/mod.rs +++ b/rust/src/view/mod.rs @@ -1,3 +1,5 @@ +use super::Context; + use maud::{html, Markup, PreEscaped, DOCTYPE}; pub mod home; @@ -9,7 +11,7 @@ pub struct Root; use crate::TopLevelPage; impl Root { - pub fn build(body: &Markup, active_page: Option<&TopLevelPage>) -> Markup { + pub fn build(context: &Context, body: &Markup, active_page: Option<&TopLevelPage>) -> Markup { let menu_item = |item: TopLevelPage, active_page: Option<&TopLevelPage>| { let active = active_page.map(|page| *page == item).unwrap_or(false); html!( @@ -88,6 +90,24 @@ impl Root { (menu_item(TopLevelPage::Inventory, active_page)) (menu_item(TopLevelPage::Trips, active_page)) } + a + ."flex" + ."flex-row" + ."items-center" + ."gap-3" + ."px-5" + ."bg-gray-200" + ."hover:bg-gray-300" + href=(format!("/user/{}", context.user.username)) + { + span + ."m-auto" + ."mdi" + ."mdi-account" + ."text-3xl" + {} + p { (context.user.username)} + } } (body) }