Compare commits

..

43 Commits

Author SHA1 Message Date
d5eba9620c Link to favicon
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 6s
Push Workflows / docs (push) Successful in 20s
Push Workflows / test (push) Successful in 23s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / build (push) Successful in 47s
Push Workflows / nix-build (push) Successful in 6m24s
2026-07-11 12:41:03 -04:00
39afdd83a7 Add username and password form input presets
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 6s
Push Workflows / docs (push) Successful in 31s
Push Workflows / clippy (push) Successful in 28s
Push Workflows / test (push) Successful in 35s
Push Workflows / build (push) Successful in 1m12s
Push Workflows / nix-build (push) Successful in 6m24s
2026-07-11 12:37:30 -04:00
34b887730a Enable account icons in Lucide 2026-07-11 12:37:30 -04:00
f2dc4c908e Export individual components from crate::components 2026-07-11 12:28:35 -04:00
219f1f5e7d Add open-signup to list of allowed endpoints 2026-07-11 12:26:28 -04:00
102387fdad Initialize tracing to INFO in release mode 2026-07-11 12:21:37 -04:00
87bd1b50a9 Add form component
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 6s
Push Workflows / docs (push) Successful in 49s
Push Workflows / clippy (push) Successful in 1m19s
Push Workflows / test (push) Successful in 1m30s
Push Workflows / build (push) Successful in 2m24s
Push Workflows / nix-build (push) Successful in 5m35s
2026-07-08 21:33:04 -04:00
c8cd4b95dc Add dioxus-html 2026-07-08 21:33:04 -04:00
8348277a87 Add endpoint to check open signup 2026-07-08 20:47:58 -04:00
e7c43e7a47 Allow icon in form input component
Misc style adjustments
2026-07-08 20:44:38 -04:00
4bacbfd1ab Add FormInput component
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 4s
Push Workflows / docs (push) Successful in 19s
Push Workflows / clippy (push) Successful in 17s
Push Workflows / test (push) Successful in 25s
Push Workflows / build (push) Successful in 51s
Push Workflows / nix-build (push) Successful in 5m13s
2026-06-30 21:29:20 -04:00
fa9ea95657 Don't check for /favicon.ico path in require_auth_mw
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / clippy (push) Successful in 28s
Push Workflows / test (push) Successful in 40s
Push Workflows / docs (push) Successful in 41s
Push Workflows / build (push) Successful in 1m50s
Push Workflows / nix-build (push) Successful in 5m17s
The server returns the icon before this middleware is reached
2026-06-30 21:17:37 -04:00
3ec07c0469 Serve favicon on /favicon.ico 2026-06-30 21:16:52 -04:00
f41f4f8d80 Set DIOXUS_PUBLIC_PATH to configured value 2026-06-30 21:11:26 -04:00
c9b7bbfece Enable fs feature on tower-http 2026-06-30 21:10:29 -04:00
9de0b13ee8 Add tower-http 2026-06-30 21:09:02 -04:00
8b00db60eb Add public_path config option 2026-06-30 21:08:05 -04:00
69eb3a5e80 Move loading config outside of router setup
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 6s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / docs (push) Successful in 23s
Push Workflows / test (push) Successful in 26s
Push Workflows / build (push) Successful in 54s
Push Workflows / nix-build (push) Successful in 5m54s
2026-06-30 21:04:16 -04:00
7133e53536 Fix clip path on logo
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / docs (push) Successful in 20s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / test (push) Successful in 26s
Push Workflows / build (push) Successful in 52s
Push Workflows / nix-build (push) Successful in 5m8s
2026-06-29 22:04:58 -04:00
ebfe18aca2 Use more descriptive errors for signup checks 2026-06-29 21:58:10 -04:00
843572b487 Add function to get source of error
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / docs (push) Successful in 21s
Push Workflows / clippy (push) Successful in 19s
Push Workflows / test (push) Successful in 27s
Push Workflows / build (push) Successful in 54s
Push Workflows / nix-build (push) Successful in 5m15s
2026-06-29 21:01:17 -04:00
2cbc6aeaff Add function to display error as alert
Move random modal id generation to function
2026-06-29 21:01:17 -04:00
f84e51d13a Add Footer component 2026-06-29 21:01:17 -04:00
4e03be050a Add logo assets to app
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / docs (push) Successful in 20s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / test (push) Successful in 26s
Push Workflows / build (push) Successful in 51s
Push Workflows / nix-build (push) Successful in 5m15s
2026-06-29 19:32:32 -04:00
32699fcf9b Reduce focus outline on input boxes 2026-06-29 19:31:44 -04:00
b0d5c45caf Add endpoint to get current user 2026-06-29 19:31:04 -04:00
280d63ed71 Add logo favicon 2026-06-28 14:44:01 -04:00
8da7f1ab66 Add logo SVG 2026-06-28 14:43:23 -04:00
3527892cfd Bring toast to front
All checks were successful
Push Workflows / rustfmt (push) Successful in 12s
Push Workflows / tailwind-build (push) Successful in 10s
Push Workflows / docs (push) Successful in 21s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / test (push) Successful in 25s
Push Workflows / build (push) Successful in 49s
Push Workflows / nix-build (push) Successful in 5m13s
2026-06-28 13:49:28 -04:00
4466396259 Enable config option for secure auth cookies
All checks were successful
Push Workflows / rustfmt (push) Successful in 16s
Push Workflows / tailwind-build (push) Successful in 15s
Push Workflows / test (push) Successful in 27s
Push Workflows / docs (push) Successful in 25s
Push Workflows / clippy (push) Successful in 19s
Push Workflows / build (push) Successful in 50s
Push Workflows / nix-build (push) Successful in 5m13s
Enable by default in release mode
2026-06-28 13:48:31 -04:00
142182af74 Allow unauthenticated access to /wasm/ in dev mode 2026-06-28 13:47:46 -04:00
0463714882 Add middleware to require authentication for some routes
All checks were successful
Push Workflows / rustfmt (push) Successful in 14s
Push Workflows / tailwind-build (push) Successful in 13s
Push Workflows / clippy (push) Successful in 25s
Push Workflows / test (push) Successful in 34s
Push Workflows / docs (push) Successful in 31s
Push Workflows / build (push) Successful in 59s
Push Workflows / nix-build (push) Successful in 5m27s
2026-06-28 11:56:18 -04:00
62a7103423 Add signup endpoint
All checks were successful
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / tailwind-build (push) Successful in 10s
Push Workflows / clippy (push) Successful in 21s
Push Workflows / test (push) Successful in 29s
Push Workflows / docs (push) Successful in 27s
Push Workflows / build (push) Successful in 52s
Push Workflows / nix-build (push) Successful in 5m17s
2026-06-27 22:34:07 -04:00
d028636e43 Add Unauthorized error 2026-06-27 22:33:50 -04:00
7ca7056fac Add Config to router as Extension 2026-06-27 22:32:57 -04:00
ab90cd81f9 Add function to create user 2026-06-27 22:32:36 -04:00
04ca8abce9 Add login and logout endpoints
All checks were successful
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / tailwind-build (push) Successful in 11s
Push Workflows / clippy (push) Successful in 43s
Push Workflows / test (push) Successful in 1m4s
Push Workflows / docs (push) Successful in 1m8s
Push Workflows / build (push) Successful in 1m30s
Push Workflows / nix-build (push) Successful in 5m27s
2026-06-27 22:18:10 -04:00
08fc3995e5 Add auth layer to router 2026-06-27 22:17:27 -04:00
4b2fb25c6d Create function to build auth layer 2026-06-27 22:17:07 -04:00
17ea0ee46a Add AuthError 2026-06-27 22:14:40 -04:00
1b5a5125a7 Derive Ser/De on User and UserCredentials 2026-06-27 22:14:10 -04:00
978c9c4202 Add tower-sessions-redis-store 2026-06-27 22:01:25 -04:00
7fc0513efc Attach db_pool to router as Extension 2026-06-27 18:54:32 -04:00
20 changed files with 643 additions and 46 deletions

67
Cargo.lock generated
View File

@@ -1096,7 +1096,7 @@ dependencies = [
"tokio-tungstenite 0.28.0", "tokio-tungstenite 0.28.0",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http 0.6.11",
"tower-layer", "tower-layer",
"tracing", "tracing",
"tungstenite 0.27.0", "tungstenite 0.27.0",
@@ -1374,7 +1374,7 @@ dependencies = [
"tokio-tungstenite 0.28.0", "tokio-tungstenite 0.28.0",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http 0.6.11",
"tracing", "tracing",
"tracing-futures", "tracing-futures",
"url", "url",
@@ -2419,6 +2419,7 @@ dependencies = [
"diesel-async", "diesel-async",
"diesel_migrations", "diesel_migrations",
"dioxus", "dioxus",
"dioxus-html",
"dotenvy", "dotenvy",
"fred", "fred",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2427,6 +2428,8 @@ dependencies = [
"rand 0.10.1", "rand 0.10.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"tower-http 0.7.0",
"tower-sessions-redis-store",
"tracing", "tracing",
] ]
@@ -3292,7 +3295,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http 0.6.11",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
@@ -3316,6 +3319,25 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]] [[package]]
name = "ron" name = "ron"
version = "0.12.1" version = "0.12.1"
@@ -4162,6 +4184,31 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tower-http"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -4211,6 +4258,20 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-sessions-redis-store"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e15b774f3d46625a27a8ac1238ecd73c8bd50013244e2de004026e161aad728"
dependencies = [
"async-trait",
"fred",
"rmp-serde",
"thiserror 2.0.18",
"time",
"tower-sessions-core",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"

View File

@@ -17,13 +17,16 @@ diesel = { version = "2.3.10", optional = true, features = ["chrono"] }
diesel-async = { version = "0.9.1", optional = true, features = ["postgres", "deadpool", "migrations"] } diesel-async = { version = "0.9.1", optional = true, features = ["postgres", "deadpool", "migrations"] }
diesel_migrations = { version = "2.3.2", optional = true } diesel_migrations = { version = "2.3.2", optional = true }
dioxus = { version = "0.7.9", features = ["router", "fullstack"] } dioxus = { version = "0.7.9", features = ["router", "fullstack"] }
dioxus-html = "0.7.9"
dotenvy = { version = "0.15.7", optional = true } dotenvy = { version = "0.15.7", optional = true }
fred = { version = "10.1.0", optional = true } fred = { version = "10.1.0", optional = true }
lucide-dioxus = { version = "3.11.0", features = ["notifications"] } lucide-dioxus = { version = "3.11.0", features = ["notifications", "account"] }
pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"] } pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"] }
rand = "0.10.1" rand = "0.10.1"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tower-http = { version = "0.7.0", optional = true, features = ["fs"] }
tower-sessions-redis-store = { version = "0.16.0", optional = true }
tracing = "0.1.44" tracing = "0.1.44"
[features] [features]
@@ -39,6 +42,8 @@ server = [
"dep:dotenvy", "dep:dotenvy",
"dep:fred", "dep:fred",
"dep:pbkdf2", "dep:pbkdf2",
"dep:tower-http",
"dep:tower-sessions-redis-store",
] ]
# Disabled until supported # Disabled until supported

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

1
assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg viewBox="0 0 254 254" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><clipPath clipPathUnits="userSpaceOnUse" id="clippath"><path fill="#4032a8" d="m 9.028871e-5,480.0001 v 0 C 9.028871e-5,214.90342 214.90341,1.0022047e-4 480.00009,1.0022047e-4 v 0 c 127.30393,0 249.39377,50.57128477953 339.41122,140.58874977953 90.01752,90.01746 140.58875,212.10733 140.58875,339.41125 v 0 c 0,265.09665 -214.90332,479.99997 -479.99997,479.99997 v 0 C 214.90341,960.00007 9.028871e-5,745.09675 9.028871e-5,480.0001 Z" fill-rule="evenodd" style="display:inline;fill:#4032a8;fill-opacity:.495496"/></clipPath><g><g style="fill:none;stroke:none;stroke-linecap:square;stroke-miterlimit:10" transform="matrix(0.26458333,0,0,0.26458333,0,4.5672766)"><g clip-path="url(#p.0)" transform="translate(0,-17.262248)"><path fill="#4032a8" d="m 9.028871e-5,480.0001 v 0 C 9.028871e-5,214.90342 214.90341,1e-4 480.00009,1e-4 v 0 c 127.30393,0 249.39377,50.571285 339.41122,140.58875 90.01752,90.01746 140.58875,212.10733 140.58875,339.41125 v 0 c 0,265.09665 -214.90332,479.99997 -479.99997,479.99997 v 0 C 214.90341,960.00007 9.028871e-5,745.09675 9.028871e-5,480.0001 Z" fill-rule="evenodd"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 60.00009,480.0001 v 0 c 0,-231.9596 188.0404,-420 420,-420 v 0 c 111.3909,0 218.21957,44.24988 296.98483,123.01516 78.76532,78.76527 123.01514,185.59392 123.01514,296.98486 v 0 c 0,231.95956 -188.0404,419.99997 -419.99997,419.99997 v 0 c -231.9596,0 -420,-188.0404 -420,-419.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 120.00009,480.0001 v 0 c 0,-198.82251 161.17749,-360 360,-360 v 0 c 95.47794,0 187.04532,37.92846 254.55844,105.44157 67.51312,67.51309 105.44153,159.0805 105.44153,254.55844 v 0 c 0,198.82248 -161.17749,359.99997 -359.99997,359.99997 v 0 c -198.82251,0 -360,-161.17749 -360,-359.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path fill="#ffffff" d="m 319.01666,480.00092 v 0 c 0,-88.90915 72.0751,-160.98425 160.98425,-160.98425 v 0 c 42.69568,0 83.64264,16.96078 113.83307,47.15121 30.19037,30.1904 47.15118,71.13736 47.15118,113.83304 v 0 c 0,88.90918 -72.07507,160.98425 -160.98425,160.98425 v 0 c -88.90915,0 -160.98425,-72.07507 -160.98425,-160.98425 z" fill-rule="evenodd" style="display:inline"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 180.00009,480.0001 v 0 c 0,-165.68542 134.31458,-300 300,-300 v 0 c 79.56497,0 155.87112,31.60704 212.13205,87.86795 56.26092,56.26092 87.86792,132.56711 87.86792,212.13205 v 0 c 0,165.6854 -134.31458,299.99997 -299.99997,299.99997 v 0 c -165.68542,0 -300,-134.31458 -300,-299.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path fill="#ffffff" d="M 427.60658,-273.65095 504.05265,93.386828 478.34507,135.07715 570.21183,450.80446 544.78065,501.65549 662.47712,1028.614 477.52388,620.42282 508.81779,566.37301 393.66837,305.06321 429.7686,227.61648 308.24258,-46.542095 Z" fill-rule="evenodd" style="stroke-width:1.16873" clip-path="url(#clippath)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

94
src/api/auth.rs Normal file
View File

@@ -0,0 +1,94 @@
use dioxus::prelude::*;
use crate::models::user::{User, UserCredentials};
use crate::util::error::Result;
cfg_if::cfg_if! {
if #[cfg(feature = "server")] {
use dioxus::server::axum::Extension;
use crate::server::{auth::{AuthSession, create_user}, config::Config, database::DbPool};
use crate::util::error::{AuthError, Contextualize, Error, ErrorType};
}
}
#[post("/api/v1/auth/signup", mut auth: Extension<AuthSession>, db_pool: Extension<DbPool>, config: Extension<Config>)]
pub async fn signup(credentials: UserCredentials) -> Result<User> {
if !config.auth.open_signup {
return Err(Error::message_here("Signup is disabled"));
}
// Don't allow signup when already logged in
if auth.user.is_some() {
return Err(Error::message_here("Log out before creating an account"));
}
let hashed_creds = credentials
.try_hash()
.map_err(|e| Error::message_here(e.to_string()))
.err_context("Error hashing new user credentials")?;
let mut db_conn = db_pool
.get()
.await
.err_context("Failed to get database pool connection")?;
let new_user = create_user(&mut db_conn, &hashed_creds)
.await
.err_context("Error creating user")?;
// Don't return this to the client, logging in immediately isn't strictly necessary
if let Err(e) = auth.login(&new_user).await {
tracing::warn!("Failed to log in user after creating: {e}");
}
Ok(new_user.into())
}
#[post("/api/v1/auth/login", mut auth: Extension<AuthSession>)]
pub async fn login(credentials: UserCredentials) -> Result<User> {
let db_user = match auth.authenticate(credentials).await {
Ok(Some(db_user)) => Ok(db_user),
Ok(None) => Err(Error::new_here(ErrorType::Auth(
AuthError::InvalidCredentials,
))),
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
AuthError::Error(format!("Session error: {e}")),
))),
Err(axum_login::Error::Backend(e)) => Err(e),
}
.err_context("Error authenticating")?;
auth.login(&db_user)
.await
.map_err(|e| Error::new_here(ErrorType::Auth(AuthError::Error(e.to_string()))))
.err_context("Error logging in")?;
Ok(db_user.into())
}
#[post("/api/v1/auth/logout", mut auth: Extension<AuthSession>)]
pub async fn logout() -> Result<()> {
match auth.logout().await {
Ok(_) => Ok(()),
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
AuthError::Error(format!("Session error: {e}")),
))),
Err(axum_login::Error::Backend(e)) => Err(e),
}
.err_context("Error logging out")
}
/// Retrieve the currently logged-in user, or `None` if unauthenticated
#[get("/api/v1/auth/user", auth: Extension<AuthSession>)]
pub async fn get_user() -> Result<Option<User>> {
Ok(auth.user.clone().map(Into::into))
}
/// Check if open signup is enabled
#[get("/api/v1/auth/open-signup", config: Extension<Config>)]
pub async fn open_signup() -> Result<bool> {
Ok(config.auth.open_signup)
}

View File

@@ -1 +1 @@
pub mod auth;

View File

@@ -1,5 +1,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
pub const LOGO_SVG: Asset = asset!("/assets/logo.svg");
pub const LOGO_ICO: Asset = asset!("/assets/favicon.ico");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
#[derive(Debug, Clone, Routable, PartialEq)] #[derive(Debug, Clone, Routable, PartialEq)]
@@ -12,6 +14,7 @@ enum Route {
#[component] #[component]
pub fn App() -> Element { pub fn App() -> Element {
rsx! { rsx! {
document::Link { rel: "icon", href: LOGO_ICO }
document::Link { rel: "stylesheet", href: TAILWIND_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {} Router::<Route> {}
} }

35
src/components/footer.rs Normal file
View File

@@ -0,0 +1,35 @@
use dioxus::prelude::*;
use crate::app::LOGO_SVG;
#[component]
pub fn Footer() -> Element {
rsx! {
footer {
class: "footer md:footer-horizontal fixed bottom-0 text-base-content/70 bg-base-300 items-center p-4",
aside {
class: "flex items-center",
img {
class: "w-12 h-12",
src: LOGO_SVG,
}
div {
p {
class: "text-xl font-bold",
"LibreTunes",
}
a {
class: "text-sm",
href: env!("CARGO_PKG_REPOSITORY"),
"v" {env!("CARGO_PKG_VERSION")},
}
}
}
}
}
}

101
src/components/form.rs Normal file
View File

@@ -0,0 +1,101 @@
use dioxus::prelude::*;
use serde::de::DeserializeOwned;
use crate::util::error::Error;
/// A simple styled form card. Parses form fields into the provided generic type, and calls the
/// callback when submitted via button or enter key.
#[component]
pub fn Form<T>(
/// Heading above the form
title: String,
/// Form content, placed inside a `fieldset`
children: Element,
/// Text displayed on the form button
action_message: String,
/// Form-related error. Set to a parsing error on parse failure, or can be used to display any
/// submission error. Clears on each submit. Displays as a card between the inputs and action
/// button, opens a modal on click.
#[props(default)]
error: Signal<Option<Error>>,
/// Whether the form should display in a "loading" state, disables the action button and
/// displays a loading animation instead. For best UX, especially on pages the user might open
/// directly as opposed to navigate to within the app, initialize to true. This component will
/// set it to false when run. This sequence will discourage users from submitting the form
/// before the page is ready to handle it. Can also be used to indicate background work is
/// taking place in the `callback`, but this is not done automatically.
#[props(default)]
loading: Signal<bool>,
/// Content to display below the action button
#[props(default)]
extra_content: Option<Element>,
/// Form submission callback. Called with the deserialized form inputs.
onsubmit: Callback<T>,
) -> Element
where
T: DeserializeOwned + 'static,
{
// Button is initialized to loading, then set back when WASM is finished loading (use_effect
// runs only on the client)
use_effect(move || loading.set(false));
rsx! {
div {
class: "card card-xl w-full sm:w-100 sm:h-fit bg-base-200",
form {
onsubmit: move |evt| {
evt.prevent_default();
let data: T = match evt.data.parsed_values() {
Ok(data) => data,
Err(e) => {
let e = Error::message_here(e.to_string())
.with_context("Failed to parse form inputs");
error.set(Some(e));
return;
}
};
error.set(None);
onsubmit.call(data);
},
class: "card-body gap-3",
h1 {
class: "card-title",
{title}
}
fieldset {
class: "fieldset pt-0 gap-1",
{children}
}
{error().map(|e| e.as_alert())}
div {
class: "card-actions gap-4",
button {
class: "btn btn-primary btn-block",
disabled: loading(),
if loading() {
span {
class: "loading loading-dots"
}
} else {
{action_message}
}
}
{extra_content}
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
use dioxus::prelude::*;
/// A simple styled input box
#[component]
pub fn FormInput(
/// The placeholder text and label. When text is entered in the input, the label is shown above
/// the input box.
label: String,
#[props(default)] name: Option<String>,
#[props(default)] required: bool,
#[props(default)] r#type: String,
children: Element,
) -> Element {
let name = name.unwrap_or(label.to_lowercase());
rsx! {
div {
class: "group/field",
p {
class: "opacity-0 not-group-has-placeholder-shown/field:opacity-70 transition-opacity pl-2",
{label.clone()}
}
label {
class: "input w-full",
{children}
input {
name,
required,
r#type,
placeholder: label,
}
}
}
}
}
#[component]
pub fn UsernameInput() -> Element {
rsx! {
FormInput {
label: "Username",
required: true,
lucide_dioxus::UserRound {
class: "opacity-50",
}
}
}
}
#[component]
pub fn PasswordInput() -> Element {
rsx! {
FormInput {
label: "Password",
required: true,
r#type: "password",
lucide_dioxus::KeyRound {
class: "opacity-50",
}
}
}
}

View File

@@ -1 +1,7 @@
pub mod footer;
pub mod form;
pub mod form_input;
pub use footer::*;
pub use form::*;
pub use form_input::*;

View File

@@ -16,6 +16,9 @@ use crate::app::App;
fn tracing_setup() { fn tracing_setup() {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger"); dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger");
#[cfg(not(debug_assertions))]
dioxus::logger::init(tracing::Level::INFO).expect("Failed to initialize tracing logger");
} }
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]

View File

@@ -1,8 +1,10 @@
//! Various user types. Some types marked server-only to help prevent //! Various user types. Some types marked server-only to help prevent
//! leaking passwords to the frontend //! leaking passwords to the frontend
use serde::{Deserialize, Serialize};
/// Standard informational user type, contains no password information /// Standard informational user type, contains no password information
#[derive(Clone, Debug)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users, #[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
check_for_backend(diesel::pg::Pg)))] check_for_backend(diesel::pg::Pg)))]
@@ -13,6 +15,7 @@ pub struct User {
} }
/// Plaintext user credentials, used for login/signup form /// Plaintext user credentials, used for login/signup form
#[derive(Deserialize, Serialize)]
pub struct UserCredentials { pub struct UserCredentials {
pub username: String, pub username: String,
pub password: String, pub password: String,

View File

@@ -1,11 +1,18 @@
use axum_login::{AuthUser, AuthnBackend, UserId}; use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
use diesel::prelude::*; use diesel::prelude::*;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use tower_sessions_redis_store::RedisStore;
use crate::models::user::{DbUser, UserCredentials}; use crate::models::user::{DbUser, HashedUserCredentials, UserCredentials};
use crate::server::database::{DbConn, DbPool}; use crate::server::{
database::{DbConn, DbPool},
key_val_store::KeyValPool,
};
use crate::util::error::{Contextualize, Error, Result}; use crate::util::error::{Contextualize, Error, Result};
pub type AuthLayer = AuthManagerLayer<AuthBackend, RedisStore<KeyValPool>>;
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
impl AuthUser for DbUser { impl AuthUser for DbUser {
type Id = i32; type Id = i32;
@@ -69,6 +76,17 @@ impl AuthnBackend for AuthBackend {
} }
} }
pub async fn create_user(
db_conn: &mut DbConn,
credentials: &HashedUserCredentials,
) -> Result<DbUser> {
diesel::insert_into(crate::schema::users::table)
.values(credentials)
.get_result(db_conn)
.await
.err_context("Error creating user")
}
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> { pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
crate::schema::users::table crate::schema::users::table
.find(id) .find(id)
@@ -89,3 +107,16 @@ pub async fn get_user_by_username(
.optional() .optional()
.err_context("Error fetching user from database by username") .err_context("Error fetching user from database by username")
} }
/// Create the authentication middleware layer
pub fn build_auth_layer(db_pool: DbPool, key_val_pool: KeyValPool, use_secure: bool) -> AuthLayer {
use axum_login::{AuthManagerLayerBuilder, tower_sessions::SessionManagerLayer};
use tower_sessions_redis_store::RedisStore;
let auth_session_store = RedisStore::new(key_val_pool);
let session_layer = SessionManagerLayer::new(auth_session_store).with_secure(use_secure);
let auth_backend = AuthBackend { db_pool };
AuthManagerLayerBuilder::new(auth_backend, session_layer).build()
}

View File

@@ -1,8 +1,15 @@
use std::path::PathBuf;
use serde::Deserialize; use serde::Deserialize;
/// Enable secure cookies by default only in release mode
/// (Secure cookies can't be set over HTTP. Rough assumption: development is done over HTTP)
const DEFAULT_COOKIES_SECURE: bool = cfg!(not(debug_assertions));
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig { pub struct AuthConfig {
pub open_signup: bool, pub open_signup: bool,
pub cookies_secure: bool,
} }
/// Build a connection URI from parts /// Build a connection URI from parts
@@ -136,12 +143,18 @@ impl KeyValStoreConfig {
} }
} }
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
pub public_path: PathBuf,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
/// Top-level application configuration /// Top-level application configuration
pub struct Config { pub struct Config {
pub auth: AuthConfig, pub auth: AuthConfig,
pub database: DatabaseConfig, pub database: DatabaseConfig,
pub key_val_store: KeyValStoreConfig, pub key_val_store: KeyValStoreConfig,
pub server: ServerConfig,
} }
/// Parse configuration from the expected files and environment variables /// Parse configuration from the expected files and environment variables
@@ -153,6 +166,8 @@ pub fn load_config() -> Result<Config, config::ConfigError> {
config::Config::builder() config::Config::builder()
.set_default("server.port", 8080)? .set_default("server.port", 8080)?
.set_default("auth.open_signup", false)? .set_default("auth.open_signup", false)?
.set_default("auth.cookies_secure", DEFAULT_COOKIES_SECURE)?
.set_default("server.public_path", default_public_dir())?
.add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false)) .add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false))
.add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false)) .add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false))
.add_source(File::with_name("config").required(false)) .add_source(File::with_name("config").required(false))
@@ -160,3 +175,19 @@ pub fn load_config() -> Result<Config, config::ConfigError> {
.build()? .build()?
.try_deserialize() .try_deserialize()
} }
/// Provide a sane default for the public path, using the same sources as Dioxus does internally.
/// Checks the `DIOXUS_PUBLIC_PATH` environment variable, then tries relative to the path of this
/// executable
fn default_public_dir() -> Option<String> {
std::env::var("DIOXUS_PUBLIC_PATH").ok().or_else(|| {
std::env::current_exe().ok().and_then(|path| {
path.parent()
.expect("current executable path must have a parent")
.join("public")
.into_os_string()
.into_string()
.ok()
})
})
}

View File

@@ -1,7 +1,17 @@
use dioxus::fullstack::axum::Router; use dioxus::{
fullstack::axum::{Router, middleware::from_fn},
server::axum::Extension,
};
use tower_http::services::ServeFile;
use crate::App; use crate::App;
use crate::server::{config, database, key_val_store}; use crate::app::LOGO_ICO;
use crate::server::{
auth::build_auth_layer,
config::{self, Config},
database, key_val_store,
require_auth_mw::require_auth_middleware,
};
use crate::util::error::{Contextualize, Error, Result}; use crate::util::error::{Contextualize, Error, Result};
pub fn main() -> Result<std::convert::Infallible> { pub fn main() -> Result<std::convert::Infallible> {
@@ -9,25 +19,59 @@ pub fn main() -> Result<std::convert::Infallible> {
tracing::warn!("Error reading .env: {e}"); tracing::warn!("Error reading .env: {e}");
} }
// `Ok(...?)` is because `dioxus::serve` expects an `anyhow::Result`
dioxus::serve(async move || Ok(router_setup().await?));
}
/// Set up the axum Router
async fn router_setup() -> Result<Router> {
tracing::debug!("Loading configuration..."); tracing::debug!("Loading configuration...");
let config = config::load_config() let config = config::load_config()
.map_err(|e| Error::message_here(e.to_string())) .map_err(|e| Error::message_here(e.to_string()))
.err_context("Failed to load config")?; .err_context("Failed to load config")?;
let _db_pool = database::setup(config.database.connection_uri()) // Dioxus doesn't expose a way to configure the public path other than this environment
// variable, and also doesn't provide a way to read what the public path is. As a workaround we
// expose a config option and set this variable that Dioxus expects.
// SAFETY: "This function is safe to call in a single-threaded program."
unsafe {
std::env::set_var("DIOXUS_PUBLIC_PATH", config.server.public_path.clone());
}
// `Ok(...?)` is because `dioxus::serve` expects an `anyhow::Result`
dioxus::serve(move || {
let config = config.clone();
async move { Ok(router_setup(config).await?) }
});
}
/// Set up the axum Router
async fn router_setup(config: Config) -> Result<Router> {
let db_pool = database::setup(config.database.connection_uri())
.await .await
.err_context("Failed database setup")?; .err_context("Failed database setup")?;
let _key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri()) let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
.await .await
.err_context("Failed key-value store setup")?; .err_context("Failed key-value store setup")?;
let favicon_path = {
// Resolve the favicon path
let asset_path = LOGO_ICO.resolve();
// If the asset path starts with "/", strip it. Otherwise it behaves as an "absolute path"
// and replaces the base path in the join operation. This is necessary because Dioxus will
// produce a path like "/assets/" in the call to `resolve`
let asset_path_rel = asset_path.strip_prefix("/").unwrap_or(&asset_path);
config.server.public_path.join(asset_path_rel)
};
tracing::debug!("Favicon path: {}", favicon_path.display());
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool, config.auth.cookies_secure);
let router = dioxus::server::router(App)
.layer(from_fn(require_auth_middleware))
.layer(Extension(config))
.layer(Extension(db_pool))
.layer(auth_layer)
.nest_service("/favicon.ico", ServeFile::new(favicon_path));
tracing::info!("Setup complete, returning Router..."); tracing::info!("Setup complete, returning Router...");
Ok(dioxus::server::router(App)) Ok(router)
} }

View File

@@ -3,5 +3,6 @@ pub mod config;
pub mod database; pub mod database;
pub mod key_val_store; pub mod key_val_store;
pub mod main; pub mod main;
pub mod require_auth_mw;
pub use main::main; pub use main::main;

View File

@@ -0,0 +1,63 @@
use dioxus::fullstack::{
axum::{body::Body, extract::Request, middleware::Next},
extract::FromRequestParts,
http::Response,
};
use dioxus::prelude::*;
use crate::server::auth::AuthSession;
#[cfg(not(debug_assertions))]
const ALLOWED_PATH_PREFIX: [&str; 1] = ["/assets/"];
#[cfg(debug_assertions)]
const ALLOWED_PATH_PREFIX: [&str; 2] = ["/assets/", "/wasm/"];
const ALLOWED_PATHS: [&str; 6] = [
"/login",
"/signup",
"/api/v1/auth/login",
"/api/v1/auth/signup",
"/api/v1/auth/user",
"/api/v1/auth/open-signup",
];
/// Axum middleware to redirect an unauthenticated request to /login unless it matches one of the allowed paths
pub async fn require_auth_middleware(
req: Request,
next: Next,
) -> Result<Response<Body>, (StatusCode, &'static str)> {
let path = req.uri().path();
if ALLOWED_PATH_PREFIX
.iter()
.any(|prefix| path.starts_with(prefix))
|| ALLOWED_PATHS.contains(&path)
{
let response = next.run(req).await;
return Ok(response);
}
let (mut parts, body) = req.into_parts();
let auth_session = AuthSession::from_request_parts(&mut parts, &()).await?;
if auth_session.user.is_none() {
let response = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header("Location", "/login")
.body(Body::empty())
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build response",
)
})?;
return Ok(response);
}
let req = Request::from_parts(parts, body);
let response = next.run(req).await;
Ok(response)
}

View File

@@ -45,6 +45,20 @@ impl fmt::Display for ErrorLocation {
pub type Result<T, E = Error> = std::result::Result<T, E>; pub type Result<T, E = Error> = std::result::Result<T, E>;
/// Generate a random string to use as an id for the modal
/// This allows multiple toast/modal to be present
fn rand_modal_id() -> String {
use rand::RngExt;
let mut rng = rand::rng();
let random_str = (0..5)
.map(|_| rng.sample(rand::distr::Alphanumeric) as char)
.collect::<String>();
format!("err-modal-{random_str}")
}
#[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)] #[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)]
pub struct Error { pub struct Error {
#[source] #[source]
@@ -97,6 +111,12 @@ impl Error {
} }
} }
/// Get the original cause of this error
/// Like `std::error::Error::source` but doesn't return an `Option`
pub fn source(&self) -> &ErrorType {
&self.source
}
/// Display this error as a modal dialog activated by a checkbox with the given id /// Display this error as a modal dialog activated by a checkbox with the given id
pub fn as_modal(&self, id: String) -> Element { pub fn as_modal(&self, id: String) -> Element {
rsx! { rsx! {
@@ -173,28 +193,9 @@ impl Error {
} }
} }
/// Convert this error to a toast message, which opens a modal when clicked /// Convert this error into an alert label, tied to the given modal id
pub fn as_toast(&self) -> Element { fn as_alert_for(&self, modal_id: String) -> Element {
// Generate a random string to use as an id for the modal
// This allows multiple toast/modal to be present
let modal_id = {
use rand::RngExt;
let mut rng = rand::rng();
let random_str = (0..5)
.map(|_| rng.sample(rand::distr::Alphanumeric) as char)
.collect::<String>();
format!("err-modal-{random_str}")
};
rsx! { rsx! {
{self.as_modal(modal_id.clone())}
div {
class: "toast",
label { label {
class: "alert alert-error alert-soft cursor-pointer", class: "alert alert-error alert-soft cursor-pointer",
role: "alert", role: "alert",
@@ -208,6 +209,29 @@ impl Error {
} }
} }
} }
/// Convert this error to a toast message, which opens a modal when clicked
pub fn as_toast(&self) -> Element {
let modal_id = rand_modal_id();
rsx! {
{self.as_modal(modal_id.clone())}
div {
class: "toast z-99",
{self.as_alert_for(modal_id)}
}
}
}
/// Convert this error into an alert label, which opens a modal when clicked
pub fn as_alert(&self) -> Element {
let modal_id = rand_modal_id();
rsx! {
{self.as_modal(modal_id.clone())}
{self.as_alert_for(modal_id)}
}
} }
} }
@@ -239,6 +263,9 @@ impl From<ServerFnError> for Error {
impl dioxus_fullstack::AsStatusCode for Error { impl dioxus_fullstack::AsStatusCode for Error {
fn as_status_code(&self) -> StatusCode { fn as_status_code(&self) -> StatusCode {
match &self.source { match &self.source {
ErrorType::Auth(AuthError::InvalidCredentials | AuthError::Unauthorized) => {
StatusCode::UNAUTHORIZED
}
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => { ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
} }
@@ -282,6 +309,9 @@ impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)] #[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
pub enum ErrorType { pub enum ErrorType {
#[error("Authentication error: {0}")]
Auth(AuthError),
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`, // Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
// and Diesel is only available on the server // and Diesel is only available on the server
#[error("Database error: {0}")] #[error("Database error: {0}")]
@@ -331,3 +361,15 @@ impl From<fred::error::Error> for Error {
Error::new_here(ErrorType::KeyValStore(format!("{err}"))) Error::new_here(ErrorType::KeyValStore(format!("{err}")))
} }
} }
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("{0}")]
Error(String),
#[error("Unauthorized")]
Unauthorized,
}

View File

@@ -21,4 +21,9 @@
@apply hover:text-base-content/70; @apply hover:text-base-content/70;
@apply active:text-primary active:translate-y-[.5px] active:shadow-(--btn-shadow); @apply active:text-primary active:translate-y-[.5px] active:shadow-(--btn-shadow);
} }
.input:focus-within {
outline-width: 1px;
outline-offset: 0px;
}
} }