Compare commits

...

81 Commits

Author SHA1 Message Date
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
f2a1296454 Move server setup into router_setup
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 8s
Push Workflows / docs (push) Successful in 33s
Push Workflows / clippy (push) Successful in 31s
Push Workflows / test (push) Successful in 57s
Push Workflows / build (push) Successful in 1m25s
Push Workflows / nix-build (push) Successful in 5m17s
Remove tokio (dioxus::serve provides a runtime)
2026-06-27 18:51:44 -04:00
d52c4cbe9e Add open_signup config flag
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 7s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / docs (push) Successful in 22s
Push Workflows / test (push) Successful in 26s
Push Workflows / build (push) Successful in 49s
Push Workflows / nix-build (push) Successful in 5m11s
2026-06-27 17:33:23 -04:00
1282c2b8b5 Implement axum-login
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 7s
Push Workflows / docs (push) Successful in 40s
Push Workflows / clippy (push) Successful in 46s
Push Workflows / test (push) Successful in 1m2s
Push Workflows / build (push) Successful in 1m54s
Push Workflows / nix-build (push) Successful in 5m20s
2026-06-27 17:25:46 -04:00
f7f4fd2813 Add password check function 2026-06-27 17:23:02 -04:00
46ce08e02f Add HashedPassword::auth_hash 2026-06-27 17:22:35 -04:00
8a049edeeb Add axum-login 2026-06-27 17:21:52 -04:00
0b7e25c792 Add database::DbConn type 2026-06-27 17:07:42 -04:00
554ae23175 Remove unnecessary error conversion 2026-06-27 17:07:27 -04:00
87aa18b7cc Allow error::Result type to use other error
Convenience since this Result will shadow std::result::Result when
imported
2026-06-27 17:06:24 -04:00
86291f1eb5 Convert deadpool::PoolError into Database error 2026-06-27 17:06:02 -04:00
f8e2dad58a Set up key-value store in server main
All checks were successful
Push Workflows / rustfmt (push) Successful in 13s
Push Workflows / tailwind-build (push) Successful in 12s
Push Workflows / docs (push) Successful in 38s
Push Workflows / clippy (push) Successful in 41s
Push Workflows / test (push) Successful in 48s
Push Workflows / build (push) Successful in 1m45s
Push Workflows / nix-build (push) Successful in 5m21s
2026-06-27 15:28:52 -04:00
2b800c2df4 Add key-value store setup function 2026-06-27 15:28:39 -04:00
9e3d534190 Add config for key-value store connection 2026-06-27 15:26:07 -04:00
d74479851f Add format_uri function 2026-06-27 15:25:52 -04:00
4ecbf6da15 Add error type for key-value store 2026-06-27 15:17:22 -04:00
674b58e290 Use wildcard match for Error variants without special status codes 2026-06-27 15:17:02 -04:00
ef9f88e72c Add fred 2026-06-27 15:16:20 -04:00
97cf3f62ad Add User models 2026-06-27 13:24:12 -04:00
3677b6adfa Add cfg_if 2026-06-27 13:23:57 -04:00
ca8c96306f Add pbkdf2
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 8s
Push Workflows / test (push) Successful in 40s
Push Workflows / clippy (push) Successful in 40s
Push Workflows / docs (push) Successful in 48s
Push Workflows / build (push) Successful in 1m26s
Push Workflows / nix-build (push) Successful in 5m14s
2026-06-27 12:35:05 -04:00
f4f1e4b96f Move schema module to server only
All checks were successful
Push Workflows / rustfmt (push) Successful in 4s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / docs (push) Successful in 39s
Push Workflows / clippy (push) Successful in 42s
Push Workflows / test (push) Successful in 52s
Push Workflows / build (push) Successful in 1m26s
Push Workflows / nix-build (push) Successful in 5m3s
2026-06-26 18:10:13 -04:00
1bf5c0f2da Ignore diesel lock file 2026-06-26 18:08:52 -04:00
773d8dffd1 Create users table 2026-06-26 18:08:03 -04:00
fb3afaf31c Add chrono
Enable chrono feature for diesel
2026-06-26 17:46:50 -04:00
3fae599c6f Ignore all config files
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 26s
Push Workflows / clippy (push) Successful in 37s
Push Workflows / test (push) Successful in 47s
Push Workflows / build (push) Successful in 1m15s
Push Workflows / nix-build (push) Successful in 5m8s
2026-06-23 22:08:57 -04:00
a40fc81d0e Call database setup from server main 2026-06-23 22:08:57 -04:00
cc468b5b14 Create tokio runtime for async setup tasks 2026-06-23 22:08:57 -04:00
655afa77ac Add database setup 2026-06-23 22:08:57 -04:00
91c79d124a Add diesel-async
Make appropriate feature flag changes to other diesel dependencies
2026-06-23 22:08:57 -04:00
81d8a96d59 Add database connection configuration
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 19s
Push Workflows / clippy (push) Failing after 15s
Push Workflows / build (push) Failing after 40s
Push Workflows / docs (push) Successful in 19s
Push Workflows / nix-build (push) Successful in 5m4s
2026-06-23 21:44:27 -04:00
f9c6f1afd1 Load configuration in server main 2026-06-23 21:38:04 -04:00
598215e50e Add web server launch message
All checks were successful
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / tailwind-build (push) Successful in 7s
Push Workflows / test (push) Successful in 22s
Push Workflows / clippy (push) Successful in 17s
Push Workflows / docs (push) Successful in 22s
Push Workflows / build (push) Successful in 45s
Push Workflows / nix-build (push) Successful in 5m2s
2026-06-23 21:37:35 -04:00
dfa75b97ca Return Result from server main 2026-06-23 21:32:16 -04:00
1b8d382906 Add function to create Error from message String 2026-06-23 21:24:21 -04:00
e38fa9ac0e Create Config and config_load
All checks were successful
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / tailwind-build (push) Successful in 6s
Push Workflows / test (push) Successful in 20s
Push Workflows / clippy (push) Successful in 15s
Push Workflows / docs (push) Successful in 21s
Push Workflows / build (push) Successful in 43s
Push Workflows / nix-build (push) Successful in 4m56s
2026-06-23 21:21:17 -04:00
5f910d77ca Add config 2026-06-21 22:13:23 -04:00
329bbb411b Add tokio 2026-06-21 22:04:33 -04:00
345e1cc565 Move Route and App out of main
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 17s
Push Workflows / docs (push) Successful in 17s
Push Workflows / clippy (push) Successful in 15s
Push Workflows / build (push) Successful in 42s
Push Workflows / nix-build (push) Successful in 5m0s
2026-06-21 16:45:53 -04:00
a6b635fef0 Use separate server main in main.rs
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 18s
Push Workflows / docs (push) Successful in 19s
Push Workflows / clippy (push) Successful in 15s
Push Workflows / build (push) Successful in 42s
Push Workflows / nix-build (push) Has been cancelled
2026-06-21 16:39:41 -04:00
3c58192957 Add Result type with custom Error 2026-06-21 16:36:07 -04:00
c438e60e24 Remove redundant feature gate in server main 2026-06-21 15:44:37 -04:00
3640039168 Add required conversions for returning Error from server functions
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 18s
Push Workflows / clippy (push) Successful in 14s
Push Workflows / docs (push) Successful in 18s
Push Workflows / build (push) Successful in 42s
Push Workflows / nix-build (push) Successful in 4m50s
2026-06-21 13:55:40 -04:00
9fd716c752 Add ServerFnError error variant 2026-06-21 13:51:56 -04:00
f159e12400 Contextualize error into result instead of error 2026-06-21 13:51:12 -04:00
d16a66a2f5 Use match instead of map_err for Contextualize on Result 2026-06-21 13:48:41 -04:00
1cf1bfcfbc Add Diesel error variant
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 17s
Push Workflows / docs (push) Successful in 18s
Push Workflows / clippy (push) Successful in 20s
Push Workflows / build (push) Successful in 52s
Push Workflows / nix-build (push) Successful in 4m50s
2026-06-21 12:28:01 -04:00
ff2aba4ec4 Allow contextualizing Option into Result 2026-06-21 12:27:47 -04:00
557489c5ed Add generic error variant 2026-06-21 12:27:20 -04:00
aba4556144 Create error type
Some checks failed
Push Workflows / rustfmt (push) Failing after 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / clippy (push) Failing after 15s
Push Workflows / test (push) Successful in 29s
Push Workflows / docs (push) Successful in 27s
Push Workflows / build (push) Failing after 36s
Push Workflows / nix-build (push) Successful in 4m59s
2026-06-21 12:22:33 -04:00
749b5e7864 Add rand
Enable wasm_js on getrandom
2026-06-20 23:16:15 -04:00
2b5f9d011f Use notifications category of Lucide icons 2026-06-20 23:03:36 -04:00
5f8d96b6ae Set GIT_REV in flake
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 4s
Push Workflows / test (push) Successful in 17s
Push Workflows / docs (push) Successful in 17s
Push Workflows / clippy (push) Successful in 14s
Push Workflows / build (push) Successful in 39s
Push Workflows / nix-build (push) Successful in 4m47s
2026-06-20 22:47:49 -04:00
7c2c76ebd5 Add soft error color 2026-06-20 22:38:20 -04:00
f015a0deed Set GIT_REV from build script
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 5s
Push Workflows / test (push) Successful in 22s
Push Workflows / clippy (push) Successful in 17s
Push Workflows / docs (push) Successful in 23s
Push Workflows / build (push) Successful in 51s
Push Workflows / nix-build (push) Failing after 4m1s
2026-06-20 22:35:04 -04:00
e5c968933e Add interact style 2026-06-20 14:41:31 -04:00
166b07e91f Add project information to Cargo.toml 2026-06-20 14:40:51 -04:00
836fe1adcd Add serde
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 22s
Push Workflows / docs (push) Successful in 26s
Push Workflows / test (push) Successful in 28s
Push Workflows / build (push) Successful in 52s
Push Workflows / nix-build (push) Successful in 4m54s
2026-06-18 22:06:59 -04:00
c5437ec7b3 Add thiserror 2026-06-18 21:45:54 -04:00
3fa0e6e4c9 Add utils module 2026-06-18 21:45:13 -04:00
78f528ff77 Add build script for diesel_migrations 2026-06-18 21:27:30 -04:00
fbe13f8d49 Build CSS for jobs that need the asset
All checks were successful
Push Workflows / rustfmt (push) Successful in 9s
Push Workflows / tailwind-build (push) Successful in 8s
Push Workflows / test (push) Successful in 20s
Push Workflows / docs (push) Successful in 20s
Push Workflows / clippy (push) Successful in 17s
Push Workflows / build (push) Successful in 1m55s
Push Workflows / nix-build (push) Successful in 4m55s
2026-06-17 21:40:48 -04:00
fae449acda Add tailwind-build job 2026-06-17 20:50:08 -04:00
099035a551 Use dioxus image for cicd jobs
Some checks failed
Push Workflows / rustfmt (push) Successful in 41s
Push Workflows / docs (push) Failing after 1m49s
Push Workflows / clippy (push) Failing after 1m50s
Push Workflows / test (push) Failing after 1m58s
Push Workflows / build (push) Failing after 2m43s
Push Workflows / nix-build (push) Failing after 11m2s
2026-06-17 20:28:51 -04:00
8ed632dd8b Fix hash for daisyui.js fetch
Some checks failed
Push Workflows / clippy (push) Failing after 50s
Push Workflows / build (push) Failing after 4s
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Failing after 44s
Push Workflows / test (push) Failing after 55s
Push Workflows / nix-build (push) Successful in 5m12s
2026-06-15 18:05:10 -04:00
fc8ab7a3bf Temporarily remove desktop and mobile targets 2026-06-15 18:02:44 -04:00
73fa2c4faf Move server-specific main to server module
Some checks failed
Push Workflows / build (push) Failing after 4s
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / test (push) Failing after 39s
Push Workflows / clippy (push) Failing after 36s
Push Workflows / docs (push) Failing after 43s
Push Workflows / nix-build (push) Failing after 2m6s
2026-06-15 18:00:49 -04:00
d92900de43 Add diesel_migrations
Only include on server feature
2026-06-15 17:46:05 -04:00
ea19ae94bd fmt
Some checks failed
Push Workflows / build (push) Failing after 5s
Push Workflows / rustfmt (push) Successful in 4s
Push Workflows / test (push) Failing after 37s
Push Workflows / clippy (push) Failing after 34s
Push Workflows / docs (push) Failing after 43s
Push Workflows / nix-build (push) Failing after 1m53s
2026-06-15 17:44:14 -04:00
310ba73173 Add server module
Some checks failed
Push Workflows / rustfmt (push) Failing after 5s
Push Workflows / build (push) Failing after 4s
Push Workflows / test (push) Failing after 35s
Push Workflows / clippy (push) Failing after 34s
Push Workflows / docs (push) Failing after 41s
Push Workflows / nix-build (push) Has been cancelled
Only build for server target
2026-06-15 17:42:50 -04:00
c147b480a9 Add api module
Some checks failed
Push Workflows / build (push) Failing after 6s
Push Workflows / rustfmt (push) Failing after 4s
Push Workflows / test (push) Failing after 40s
Push Workflows / clippy (push) Failing after 34s
Push Workflows / docs (push) Failing after 41s
Push Workflows / nix-build (push) Has been cancelled
2026-06-15 17:40:45 -04:00
24 changed files with 1969 additions and 2834 deletions

View File

@@ -3,34 +3,40 @@ on: push
jobs:
build:
runs-on: libretunes-cicd
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run tailwindcss
run: tailwindcss --input style/tailwind.css --output assets/tailwind.css
- name: Build project
env:
RUSTFLAGS: "-D warnings"
run: dx build
test:
runs-on: libretunes-cicd
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run tailwindcss
run: tailwindcss --input style/tailwind.css --output assets/tailwind.css
- name: Test project
run: cargo test --all-targets --all-features
docs:
runs-on: libretunes-cicd
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run tailwindcss
run: tailwindcss --input style/tailwind.css --output assets/tailwind.css
- name: Generate docs
run: cargo doc --no-deps
- name: Upload docs
@@ -50,21 +56,31 @@ jobs:
run: nix build --experimental-features 'nix-command flakes' git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git?ref=$GITHUB_REF_NAME#default
clippy:
runs-on: libretunes-cicd
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run tailwindcss
run: tailwindcss --input style/tailwind.css --output assets/tailwind.css
- name: Run clippy
env:
RUSTFLAGS: "-D warnings"
run: cargo clippy --all-targets --all-features
rustfmt:
runs-on: libretunes-cicd
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run rustfmt
run: cargo fmt --check
tailwind-build:
runs-on: libretunes-cicd-dx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tailwindcss
run: tailwindcss --input style/tailwind.css

11
.gitignore vendored
View File

@@ -7,3 +7,14 @@
/result
.env
# Anything the config crate looks for
config.ini
config.json
config.json5
config.ron
config.toml
config.yaml
config.yml
/migrations/.diesel_lock

3599
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,54 @@
[package]
name = "libretunes"
description = "Open-source audio player and library manager"
repository = "https://git.libretunes.xyz/LibreTunes/LibreTunes-DX"
license = "MIT"
version = "0.1.0"
authors = ["Ethan Girouard"]
edition = "2024"
build = "src/build.rs"
[dependencies]
diesel = { version = "2.3.10", optional = true, features = [ "postgres" ] }
axum-login = { version = "0.18.0", optional = true }
cfg-if = "1.0.4"
chrono = { version = "0.4.45", features = ["serde"] }
config = { version = "0.15.24", optional = true }
diesel = { version = "2.3.10", optional = true, features = ["chrono"] }
diesel-async = { version = "0.9.1", optional = true, features = ["postgres", "deadpool", "migrations"] }
diesel_migrations = { version = "2.3.2", optional = true }
dioxus = { version = "0.7.9", features = ["router", "fullstack"] }
dotenvy = { version = "0.15.7", optional = true }
lucide-dioxus = "3.11.0"
fred = { version = "10.1.0", optional = true }
lucide-dioxus = { version = "3.11.0", features = ["notifications"] }
pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"] }
rand = "0.10.1"
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18"
tower-sessions-redis-store = { version = "0.16.0", optional = true }
tracing = "0.1.44"
[features]
default = ["web"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
server = [
"dioxus/server",
"dep:axum-login",
"dep:config",
"dep:diesel",
"dep:diesel-async",
"dep:diesel_migrations",
"dep:dotenvy",
"dep:fred",
"dep:pbkdf2",
"dep:tower-sessions-redis-store",
]
# Disabled until supported
# desktop = ["dioxus/desktop"]
# mobile = ["dioxus/mobile"]
# Enable wasm_js in getrandom when building for wasm32
# This is a workaround for rand not exposing a wasm_js target
# https://github.com/rust-random/rand/issues/1694#issuecomment-3846362044
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom = { version = "0.4.3", features = ["wasm_js"] }

View File

@@ -45,7 +45,7 @@
src = pkgs.fetchurl {
url = "https://github.com/saadeghi/daisyui/releases/download/${daisyui-version}/daisyui.js";
sha256 = "sha256-IHsOrki86HGPW02ERe3gXTatzWJjjQ6EPQ3eodW/yXw=";
sha256 = "sha256-yu/8ebzKXMfrcHJw2FzcXNzwYOF1hC+nufrTaPOMWeA=";
};
unpackPhase = "true";
@@ -85,6 +85,8 @@
tailwindcss_4
postgresql
];
rev = if builtins.hasAttr "rev" self then self.rev else self.dirtyRev;
in
rec {
devShells.default = pkgs.mkShell {
@@ -104,6 +106,7 @@
DAISYUI_PATH = "${daisyui}";
DAISYUI_THEME_PATH = "${daisyui-theme}";
GIT_REV = rev;
cargoLock.lockFile = ./Cargo.lock;

View File

@@ -0,0 +1,2 @@
DROP INDEX users_username_idx;
DROP TABLE users;

View File

@@ -0,0 +1,8 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY UNIQUE NOT NULL GENERATED ALWAYS AS IDENTITY,
username VARCHAR UNIQUE NOT NULL,
hashed_password VARCHAR NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX users_username_idx ON users(username);

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

@@ -0,0 +1,82 @@
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::new_here(ErrorType::Auth(AuthError::Unauthorized)));
}
// Don't allow signup when already logged in
if auth.user.is_some() {
return Err(Error::new_here(ErrorType::Auth(AuthError::Unauthorized)));
}
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")
}

1
src/api/mod.rs Normal file
View File

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

29
src/app.rs Normal file
View File

@@ -0,0 +1,29 @@
use dioxus::prelude::*;
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
enum Route {
#[route("/")]
Home {},
}
#[component]
pub fn App() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {}
}
}
/// Home page
#[component]
fn Home() -> Element {
rsx! {
p {
class: "text-lg",
"Hello, world!"
}
}
}

19
src/build.rs Normal file
View File

@@ -0,0 +1,19 @@
use std::env;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=.git");
if env::var("GIT_REV").is_err() {
let git_rev: String = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.expect("Failed to run git rev-parse")
.stdout
.try_into()
.expect("Failed to parse output from git");
println!("cargo:rustc-env=GIT_REV={git_rev}");
}
}

View File

@@ -1,46 +1,35 @@
use dioxus::prelude::*;
pub mod models;
pub mod api;
pub mod app;
pub mod components;
pub mod models;
pub mod pages;
pub mod schema;
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
enum Route {
#[route("/")]
Home {},
}
#[component]
fn App() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {}
}
}
/// Home page
#[component]
fn Home() -> Element {
rsx! {
p {
class: "text-lg",
"Hello, world!"
}
}
}
fn main() {
#[cfg(debug_assertions)]
dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger");
pub mod util;
#[cfg(feature = "server")]
if let Err(e) = dotenvy::dotenv() {
tracing::warn!("Error reading .env: {e}");
pub mod schema;
#[cfg(feature = "server")]
pub mod server;
use crate::app::App;
fn tracing_setup() {
#[cfg(debug_assertions)]
dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger");
}
#[cfg(not(feature = "server"))]
fn main() {
tracing_setup();
dioxus::launch(App);
}
#[cfg(feature = "server")]
fn main() -> std::process::ExitCode {
tracing_setup();
let Err(e) = server::main();
tracing::error!("Server main failed:\n{e}");
std::process::ExitCode::FAILURE
}

View File

@@ -1 +1 @@
pub mod user;

147
src/models/user.rs Normal file
View File

@@ -0,0 +1,147 @@
//! Various user types. Some types marked server-only to help prevent
//! leaking passwords to the frontend
use serde::{Deserialize, Serialize};
/// Standard informational user type, contains no password information
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
check_for_backend(diesel::pg::Pg)))]
pub struct User {
pub id: i32,
pub username: String,
pub created_at: chrono::DateTime<chrono::Local>,
}
/// Plaintext user credentials, used for login/signup form
#[derive(Deserialize, Serialize)]
pub struct UserCredentials {
pub username: String,
pub password: String,
}
cfg_if::cfg_if! {
if #[cfg(feature = "server")] {
use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
prelude::*,
serialize::ToSql,
sql_types,
};
use pbkdf2::{
PasswordHasher, PasswordVerifier, Pbkdf2, password_hash::Error::PasswordInvalid,
phc::PasswordHash,
};
use crate::util::error::{Error, Result};
/// Newtype for a `String`-represented hashed password
#[derive(Clone, Debug, AsExpression, FromSqlRow)]
#[diesel(sql_type = sql_types::Text)]
pub struct HashedPassword(String);
impl HashedPassword {
/// Check a password attempt against this hashed password
///
/// # Returns
///
/// `Ok(true)` for a correct password
/// `Ok(false)` for an incorrect password
/// `Err` for a hashing error
pub fn check(&self, password_attempt: String) -> Result<bool> {
let pw_hash = PasswordHash::new(&self.0)
.map_err(|e| Error::message_here(format!("Error parsing `HashedPassword`: {e}")))?;
match Pbkdf2::default().verify_password(password_attempt.as_bytes(), &pw_hash) {
Ok(()) => Ok(true),
Err(PasswordInvalid) => Ok(false),
Err(e) => Err(Error::message_here(format!(
"Error comparing password attempt against hash: {e}"
))),
}
}
/// Returns the "session auth hash" for `axum-login`, just the hashed password as bytes
pub fn auth_hash(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl<DB> FromSql<diesel::sql_types::Text, DB> for HashedPassword
where
DB: diesel::backend::Backend,
String: FromSql<sql_types::Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
Ok(Self(String::from_sql(bytes)?))
}
}
impl<DB> ToSql<diesel::sql_types::Text, DB> for HashedPassword
where
DB: diesel::backend::Backend,
String: ToSql<sql_types::Text, DB>,
{
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, DB>,
) -> diesel::serialize::Result {
self.0.to_sql(out)
}
}
/// User as it appears in the database, with hashed password
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
pub struct DbUser {
pub id: i32,
pub username: String,
pub hashed_password: HashedPassword,
pub created_at: chrono::DateTime<chrono::Local>,
}
impl From<DbUser> for User {
fn from(db_user: DbUser) -> Self {
User {
id: db_user.id,
username: db_user.username,
created_at: db_user.created_at,
}
}
}
/// User credentials with hashed password
#[derive(Clone, Debug, Insertable, Queryable, Selectable)]
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
pub struct HashedUserCredentials {
username: String,
hashed_password: HashedPassword,
}
impl From<DbUser> for HashedUserCredentials {
fn from(db_user: DbUser) -> Self {
HashedUserCredentials {
username: db_user.username,
hashed_password: db_user.hashed_password,
}
}
}
impl UserCredentials {
/// Attempt to convert into `HashedUserCredentials` by hashing the password. Yields a PBKDF2
/// error on failure.
pub fn try_hash(self) -> Result<HashedUserCredentials, pbkdf2::password_hash::Error> {
let hashed_password = Pbkdf2::default().hash_password(self.password.as_bytes())?;
Ok(HashedUserCredentials {
username: self.username,
hashed_password: HashedPassword(hashed_password.to_string()),
})
}
}
}
}

View File

@@ -1 +1,10 @@
// @generated automatically by Diesel CLI.
diesel::table! {
users (id) {
id -> Int4,
username -> Varchar,
hashed_password -> Varchar,
created_at -> Timestamptz,
}
}

122
src/server/auth.rs Normal file
View File

@@ -0,0 +1,122 @@
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use tower_sessions_redis_store::RedisStore;
use crate::models::user::{DbUser, HashedUserCredentials, UserCredentials};
use crate::server::{
database::{DbConn, DbPool},
key_val_store::KeyValPool,
};
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 {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.hashed_password.auth_hash()
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub db_pool: DbPool,
}
impl AuthnBackend for AuthBackend {
type User = DbUser;
type Credentials = UserCredentials;
type Error = Error;
async fn authenticate(
&self,
attempt_creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let mut db_conn = self
.db_pool
.get()
.await
.err_context("Failed to get database pool connection")?;
let user = get_user_by_username(&mut db_conn, attempt_creds.username)
.await
.err_context("Error fetching user for authentication check")?;
let Some(user) = user else { return Ok(None) };
let password_result = user
.hashed_password
.check(attempt_creds.password)
.err_context("Error checking user password attempt")?;
if password_result {
Ok(Some(user))
} else {
Ok(None)
}
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let mut db_conn = self
.db_pool
.get()
.await
.err_context("Failed to get database pool connection")?;
get_user_by_id(&mut db_conn, *user_id)
.await
.err_context("Failed fetching user for session")
}
}
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>> {
crate::schema::users::table
.find(id)
.first(db_conn)
.await
.optional()
.err_context("Error fetching user from database by id")
}
pub async fn get_user_by_username(
db_conn: &mut DbConn,
username: String,
) -> Result<Option<DbUser>> {
crate::schema::users::table
.filter(crate::schema::users::username.eq(username))
.first(db_conn)
.await
.optional()
.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) -> 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);
let auth_backend = AuthBackend { db_pool };
AuthManagerLayerBuilder::new(auth_backend, session_layer).build()
}

162
src/server/config.rs Normal file
View File

@@ -0,0 +1,162 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
pub open_signup: bool,
}
/// Build a connection URI from parts
fn format_uri(
scheme: &str,
username: &Option<String>,
password: &Option<String>,
host: &str,
port: &Option<u16>,
path: &Option<String>,
) -> String {
let mut url = format!("{scheme}://");
if let Some(username) = username {
url.push_str(username);
if let Some(password) = password {
url.push_str(&format!(":{password}"));
}
url.push('@');
}
url.push_str(host);
if let Some(port) = port {
url.push_str(&format!(":{port}"));
}
if let Some(path) = path {
url.push_str(&format!("/{path}"));
}
url
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
#[serde(flatten)]
connection: DatabaseConnectionConfig,
}
impl DatabaseConfig {
/// Get the configured database connection URI
pub fn connection_uri(&self) -> String {
self.connection.as_uri()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum DatabaseConnectionConfig {
FromUrl {
url: String,
},
FromParts {
host: String,
port: Option<u16>,
database: Option<String>,
username: Option<String>,
password: Option<String>,
},
}
impl DatabaseConnectionConfig {
/// Convert this configuration into the Postgres connection URI
pub fn as_uri(&self) -> String {
match self {
Self::FromUrl { url } => url.clone(),
Self::FromParts {
host,
port,
database,
username,
password,
} => format_uri("postgres", username, password, host, port, database),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum KeyValStoreConnectionConfig {
FromUrl {
url: String,
},
FromParts {
scheme: Option<String>,
host: String,
port: Option<u16>,
database: Option<String>,
username: Option<String>,
password: Option<String>,
},
}
impl KeyValStoreConnectionConfig {
/// Convert this configuration into the Redis connection URI
pub fn as_uri(&self) -> String {
match self {
Self::FromUrl { url } => url.clone(),
Self::FromParts {
scheme,
host,
port,
database,
username,
password,
} => format_uri(
scheme.as_deref().unwrap_or("redis"),
username,
password,
host,
port,
database,
),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct KeyValStoreConfig {
#[serde(flatten)]
connection: KeyValStoreConnectionConfig,
}
impl KeyValStoreConfig {
/// Get the configured database connection URI
pub fn connection_uri(&self) -> String {
self.connection.as_uri()
}
}
#[derive(Debug, Clone, Deserialize)]
/// Top-level application configuration
pub struct Config {
pub auth: AuthConfig,
pub database: DatabaseConfig,
pub key_val_store: KeyValStoreConfig,
}
/// Parse configuration from the expected files and environment variables
pub fn load_config() -> Result<Config, config::ConfigError> {
use config::{Environment, File};
let pkg_name = env!("CARGO_PKG_NAME");
config::Config::builder()
.set_default("server.port", 8080)?
.set_default("auth.open_signup", 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("config").required(false))
.add_source(Environment::with_prefix(pkg_name).separator("_"))
.build()?
.try_deserialize()
}

40
src/server/database.rs Normal file
View File

@@ -0,0 +1,40 @@
use diesel_async::{
AsyncMigrationHarness, AsyncPgConnection,
pooled_connection::{AsyncDieselConnectionManager, deadpool::Pool},
};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use crate::util::error::{Contextualize, Error, ErrorType};
pub const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
pub type DbPool = Pool<AsyncPgConnection>;
pub type DbConn = AsyncPgConnection;
/// Connect to the database using the given URI, and perform migrations
pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> {
let pool_manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_uri);
let pool = Pool::builder(pool_manager)
.build()
// At time of writing only the `NoRuntimeSpecified` error is possible from the builder,
// which should only occur when configuring timeouts without a `Runtime`
.map_err(|e| ErrorType::Database(e.to_string()))
.err_context("Error creating pool for database connections")?;
tracing::debug!("Establishing connection to database for migrations...");
let migration_conn = pool
.get()
.await
.err_context("Failed to get connection to database")?;
tracing::debug!("Running migrations...");
AsyncMigrationHarness::new(migration_conn)
.run_pending_migrations(DB_MIGRATIONS)
.map_err(|e| ErrorType::Database(e.to_string()))
.err_context("Failed to run pending database migrations")?;
Ok(pool)
}

View File

@@ -0,0 +1,29 @@
use fred::prelude::*;
use crate::util::error::{Contextualize, Error, ErrorType};
const KEY_VAL_POOL_SIZE: usize = 4;
pub type KeyValPool = Pool;
pub async fn setup(connection_uri: &str) -> Result<KeyValPool, Error> {
let config = Config::from_url(connection_uri)
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
.err_context("Error creating key-value store config")?;
let pool = Builder::from_config(config)
.build_pool(KEY_VAL_POOL_SIZE)
// At time of writing the only error that could occur here is if config is not provided.
// Since we're building a pool `from_config`, this shouldn't be possible
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
.err_context("Error creating pool for key-value store")?;
tracing::debug!("Establishing connection to key-value store...");
pool.init()
.await
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
.err_context("Error connecting to key-value store")?;
Ok(pool)
}

40
src/server/main.rs Normal file
View File

@@ -0,0 +1,40 @@
use dioxus::{fullstack::axum::Router, server::axum::Extension};
use crate::App;
use crate::server::{auth::build_auth_layer, config, database, key_val_store};
use crate::util::error::{Contextualize, Error, Result};
pub fn main() -> Result<std::convert::Infallible> {
if let Err(e) = dotenvy::dotenv() {
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...");
let config = config::load_config()
.map_err(|e| Error::message_here(e.to_string()))
.err_context("Failed to load config")?;
let db_pool = database::setup(config.database.connection_uri())
.await
.err_context("Failed database setup")?;
let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
.await
.err_context("Failed key-value store setup")?;
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool);
let router = dioxus::server::router(App)
.layer(Extension(config))
.layer(Extension(db_pool))
.layer(auth_layer);
tracing::info!("Setup complete, returning Router...");
Ok(router)
}

7
src/server/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod auth;
pub mod config;
pub mod database;
pub mod key_val_store;
pub mod main;
pub use main::main;

351
src/util/error.rs Normal file
View File

@@ -0,0 +1,351 @@
use std::fmt;
use std::panic::Location;
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
/// A location in the source code
/// A thin wrapper over `std::panic::Location`, which isn't `Serialize` or `Deserialize`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorLocation {
file: String,
line: u32,
}
impl ErrorLocation {
/// Creates a new `ErrorLocation` with the file and line number of the caller.
#[track_caller]
pub fn here() -> Self {
let location = Location::caller();
ErrorLocation {
file: location.file().to_string(),
line: location.line(),
}
}
/// Get a link to the source code based on the repository URL from Cargo and the Git commit
/// from the build script. Uses a format supported by GitHub, Gitea, and GitLab.
pub fn source_link(&self) -> String {
format!(
"{}/blob/{}/{}#L{}",
env!("CARGO_PKG_REPOSITORY"),
env!("GIT_REV"),
self.file,
self.line
)
}
}
impl fmt::Display for ErrorLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.file, self.line)
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)]
pub struct Error {
#[source]
/// The error type and data
source: ErrorType,
/// The location where the error was created
from: ErrorLocation,
/// Context added to the error, and location where it was added
context: Vec<(ErrorLocation, String)>,
}
impl Error {
/// Creates a new `Error` at this location with the given error type
#[track_caller]
pub fn new_here(source: ErrorType) -> Self {
Error::new(source, ErrorLocation::here())
}
/// Creates a new `Error` with the given location and error type
pub fn new(source: ErrorType, from: ErrorLocation) -> Self {
Error {
source,
from,
context: Vec::new(),
}
}
/// Creates a new basic `Error` at this location with the given message
#[track_caller]
pub fn message_here<S: Into<String>>(message: S) -> Self {
Error::new(ErrorType::Error(message.into()), ErrorLocation::here())
}
/// Adds a context message to the error
#[track_caller]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context.push((ErrorLocation::here(), context.into()));
self
}
/// Retrieve the "top" message of the error. Uses either the most recent context entry, or the
/// source message if no context has been added.
pub fn top_message(&self) -> String {
if let Some((_location, message)) = self.context.last() {
message.clone()
} else {
self.source.to_string()
}
}
/// Display this error as a modal dialog activated by a checkbox with the given id
pub fn as_modal(&self, id: String) -> Element {
rsx! {
input {
r#type: "checkbox",
class: "modal-toggle",
id: &id,
}
div {
class: "modal",
role: "dialog",
div {
class: "modal-box border border-error bg-soft-error max-w-200",
h2 {
class: "flex items-center gap-3 text-lg",
lucide_dioxus::CircleAlert {
class: "shrink-0",
}
{self.top_message()}
}
p {
class: "text-base-content/70 py-3",
"Details"
}
div {
class: "md:grid md:grid-cols-[fit-content(calc(var(--spacing)*30))_auto] md:gap-1 md:gap-x-2 mb-6",
for (location, message) in self.context.iter().rev().chain(std::iter::once(&(
self.from.clone(),
self.source.to_string(),
))) {
a {
class: "text-base-content/80 interact underline",
target: "_blank",
href: location.source_link(),
{location.to_string()}
}
p {
class: "ml-3 md:ml-0",
{message.to_string()}
}
}
}
a {
class: "text-base-content/50 interact underline",
target: "_blank",
href: format!("{}/issues/new", env!("CARGO_PKG_REPOSITORY")),
"Report an issue"
}
label {
class: "absolute right-1 top-1 interact hover:bg-base-100/70 p-1 rounded-full",
r#for: &id,
lucide_dioxus::X {
class: "size-7 md:size-5",
}
}
}
label {
class: "modal-backdrop cursor-pointer",
r#for: id,
"Close",
}
}
}
}
/// Convert this error to a toast message, which opens a modal when clicked
pub fn as_toast(&self) -> 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! {
{self.as_modal(modal_id.clone())}
div {
class: "toast",
label {
class: "alert alert-error alert-soft cursor-pointer",
role: "alert",
r#for: modal_id,
lucide_dioxus::CircleAlert {}
p {
class: "max-w-120 text-ellipsis line-clamp-3",
{self.top_message()}
}
}
}
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Write the error type and its context
writeln!(f, "Error: {}", self.top_message())?;
write!(f, "Context:")?;
for (location, message) in self.context.iter().rev().chain(std::iter::once(&(
self.from.clone(),
self.source.to_string(),
))) {
write!(f, "\n - {location}: {message}")?;
}
Ok(())
}
}
impl From<ServerFnError> for Error {
#[track_caller]
fn from(err: ServerFnError) -> Error {
Error::new_here(ErrorType::ServerFnError(err))
}
}
#[cfg(feature = "server")]
impl dioxus_fullstack::AsStatusCode for Error {
fn as_status_code(&self) -> StatusCode {
match &self.source {
ErrorType::Auth(AuthError::InvalidCredentials | AuthError::Unauthorized) => {
StatusCode::UNAUTHORIZED
}
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
StatusCode::NOT_FOUND
}
ErrorType::ServerFnError(e) => e.as_status_code(),
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
pub trait Contextualize<R> {
/// Add context to the `Result` if it is an `Err`.
#[track_caller]
fn err_context(self, context: impl Into<String>) -> R;
}
impl<T, E: Into<Error>> Contextualize<Result<T>> for std::result::Result<T, E> {
#[track_caller]
fn err_context(self, context: impl Into<String>) -> Result<T> {
// Closures can't (currently) `track_caller`, so a simple map_err doesn't work
// See https://github.com/rust-lang/rust/issues/87417
match self {
Ok(e) => Ok(e),
Err(e) => Err(e.into().with_context(context)),
}
}
}
impl<T> Contextualize<Result<T>> for Option<T> {
#[track_caller]
fn err_context(self, context: impl Into<String>) -> Result<T> {
self.ok_or(Error::new_here(ErrorType::Error(context.into())))
}
}
impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
#[track_caller]
fn err_context(self, context: impl Into<String>) -> Result<T> {
Err(self.into().with_context(context))
}
}
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
pub enum ErrorType {
#[error("Authentication error: {0}")]
Auth(AuthError),
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
// and Diesel is only available on the server
#[error("Database error: {0}")]
Database(String),
#[error("{0}")]
Error(String),
#[error("Server function error: {0}")]
ServerFnError(ServerFnError),
// Using string to represent Fred errors, because Fred's Error type is not `Serialize`,
// and Fred is only available on the server
#[error("Key-value store error: {0}")]
KeyValStore(String),
}
impl From<ErrorType> for Error {
#[track_caller]
fn from(err: ErrorType) -> Self {
Error::new_here(err)
}
}
#[cfg(feature = "server")]
impl From<diesel::result::Error> for Error {
#[track_caller]
fn from(err: diesel::result::Error) -> Self {
Error::new_here(ErrorType::Database(format!("{err}")))
}
}
// This would capture any `deapool::PoolError` and treat it as a database error
// but we're only using `deadpool` for our database, so it's fine
#[cfg(feature = "server")]
impl From<diesel_async::pooled_connection::deadpool::PoolError> for Error {
#[track_caller]
fn from(err: diesel_async::pooled_connection::deadpool::PoolError) -> Self {
Error::new_here(ErrorType::Database(format!("{err}")))
}
}
#[cfg(feature = "server")]
impl From<fred::error::Error> for Error {
#[track_caller]
fn from(err: fred::error::Error) -> Self {
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,
}

1
src/util/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod error;

View File

@@ -9,3 +9,16 @@
@source not "*";
@source "./src/**/*.{rs,html,css}";
@theme {
/* Copied out of DaisyUI theme, which doesn't make the color available */
--color-soft-error: color-mix(in oklab, var(--color-error, var(--color-base-content)) 8%, var(--color-base-100));
}
@layer utilities {
.interact {
@apply cursor-pointer;
@apply hover:text-base-content/70;
@apply active:text-primary active:translate-y-[.5px] active:shadow-(--btn-shadow);
}
}