Compare commits
17 Commits
69eb3a5e80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
d5eba9620c
|
|||
|
39afdd83a7
|
|||
|
34b887730a
|
|||
|
f2dc4c908e
|
|||
|
219f1f5e7d
|
|||
|
102387fdad
|
|||
|
87bd1b50a9
|
|||
|
c8cd4b95dc
|
|||
|
8348277a87
|
|||
|
e7c43e7a47
|
|||
|
4bacbfd1ab
|
|||
|
fa9ea95657
|
|||
|
3ec07c0469
|
|||
|
f41f4f8d80
|
|||
|
c9b7bbfece
|
|||
|
9de0b13ee8
|
|||
|
8b00db60eb
|
33
Cargo.lock
generated
33
Cargo.lock
generated
@@ -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,7 @@ 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",
|
"tower-sessions-redis-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -3293,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",
|
||||||
@@ -4182,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"
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ 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 }
|
tower-sessions-redis-store = { version = "0.16.0", optional = true }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ server = [
|
|||||||
"dep:dotenvy",
|
"dep:dotenvy",
|
||||||
"dep:fred",
|
"dep:fred",
|
||||||
"dep:pbkdf2",
|
"dep:pbkdf2",
|
||||||
|
"dep:tower-http",
|
||||||
"dep:tower-sessions-redis-store",
|
"dep:tower-sessions-redis-store",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,9 @@ pub async fn logout() -> Result<()> {
|
|||||||
pub async fn get_user() -> Result<Option<User>> {
|
pub async fn get_user() -> Result<Option<User>> {
|
||||||
Ok(auth.user.clone().map(Into::into))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,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> {}
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/components/form.rs
Normal file
101
src/components/form.rs
Normal 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/components/form_input.rs
Normal file
68
src/components/form_input.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,7 @@
|
|||||||
pub mod footer;
|
pub mod footer;
|
||||||
|
pub mod form;
|
||||||
|
pub mod form_input;
|
||||||
|
|
||||||
|
pub use footer::*;
|
||||||
|
pub use form::*;
|
||||||
|
pub use form_input::*;
|
||||||
|
|||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
/// Enable secure cookies by default only in release mode
|
/// Enable secure cookies by default only in release mode
|
||||||
@@ -141,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
|
||||||
@@ -159,6 +167,7 @@ pub fn load_config() -> Result<Config, config::ConfigError> {
|
|||||||
.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("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))
|
||||||
@@ -166,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ use dioxus::{
|
|||||||
fullstack::axum::{Router, middleware::from_fn},
|
fullstack::axum::{Router, middleware::from_fn},
|
||||||
server::axum::Extension,
|
server::axum::Extension,
|
||||||
};
|
};
|
||||||
|
use tower_http::services::ServeFile;
|
||||||
|
|
||||||
use crate::App;
|
use crate::App;
|
||||||
|
use crate::app::LOGO_ICO;
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
auth::build_auth_layer,
|
auth::build_auth_layer,
|
||||||
config::{self, Config},
|
config::{self, Config},
|
||||||
@@ -22,6 +24,14 @@ pub fn main() -> Result<std::convert::Infallible> {
|
|||||||
.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")?;
|
||||||
|
|
||||||
|
// 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`
|
// `Ok(...?)` is because `dioxus::serve` expects an `anyhow::Result`
|
||||||
dioxus::serve(move || {
|
dioxus::serve(move || {
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
@@ -39,13 +49,28 @@ async fn router_setup(config: Config) -> Result<Router> {
|
|||||||
.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 auth_layer = build_auth_layer(db_pool.clone(), key_val_pool, config.auth.cookies_secure);
|
||||||
|
|
||||||
let router = dioxus::server::router(App)
|
let router = dioxus::server::router(App)
|
||||||
.layer(from_fn(require_auth_middleware))
|
.layer(from_fn(require_auth_middleware))
|
||||||
.layer(Extension(config))
|
.layer(Extension(config))
|
||||||
.layer(Extension(db_pool))
|
.layer(Extension(db_pool))
|
||||||
.layer(auth_layer);
|
.layer(auth_layer)
|
||||||
|
.nest_service("/favicon.ico", ServeFile::new(favicon_path));
|
||||||
|
|
||||||
tracing::info!("Setup complete, returning Router...");
|
tracing::info!("Setup complete, returning Router...");
|
||||||
Ok(router)
|
Ok(router)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const ALLOWED_PATHS: [&str; 6] = [
|
|||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
"/api/v1/auth/signup",
|
"/api/v1/auth/signup",
|
||||||
"/api/v1/auth/user",
|
"/api/v1/auth/user",
|
||||||
"/favicon.ico",
|
"/api/v1/auth/open-signup",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Axum middleware to redirect an unauthenticated request to /login unless it matches one of the allowed paths
|
/// Axum middleware to redirect an unauthenticated request to /login unless it matches one of the allowed paths
|
||||||
|
|||||||
Reference in New Issue
Block a user