Compare commits
65 Commits
73fa2c4faf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f2a1296454
|
|||
|
d52c4cbe9e
|
|||
|
1282c2b8b5
|
|||
|
f7f4fd2813
|
|||
|
46ce08e02f
|
|||
|
8a049edeeb
|
|||
|
0b7e25c792
|
|||
|
554ae23175
|
|||
|
87aa18b7cc
|
|||
|
86291f1eb5
|
|||
|
f8e2dad58a
|
|||
|
2b800c2df4
|
|||
|
9e3d534190
|
|||
|
d74479851f
|
|||
|
4ecbf6da15
|
|||
|
674b58e290
|
|||
|
ef9f88e72c
|
|||
|
97cf3f62ad
|
|||
|
3677b6adfa
|
|||
|
ca8c96306f
|
|||
|
f4f1e4b96f
|
|||
|
1bf5c0f2da
|
|||
|
773d8dffd1
|
|||
|
fb3afaf31c
|
|||
|
3fae599c6f
|
|||
|
a40fc81d0e
|
|||
|
cc468b5b14
|
|||
|
655afa77ac
|
|||
|
91c79d124a
|
|||
|
81d8a96d59
|
|||
|
f9c6f1afd1
|
|||
|
598215e50e
|
|||
|
dfa75b97ca
|
|||
|
1b8d382906
|
|||
|
e38fa9ac0e
|
|||
|
5f910d77ca
|
|||
|
329bbb411b
|
|||
|
345e1cc565
|
|||
|
a6b635fef0
|
|||
|
3c58192957
|
|||
|
c438e60e24
|
|||
|
3640039168
|
|||
|
9fd716c752
|
|||
|
f159e12400
|
|||
|
d16a66a2f5
|
|||
|
1cf1bfcfbc
|
|||
|
ff2aba4ec4
|
|||
|
557489c5ed
|
|||
|
aba4556144
|
|||
|
749b5e7864
|
|||
|
2b5f9d011f
|
|||
|
5f8d96b6ae
|
|||
|
7c2c76ebd5
|
|||
|
f015a0deed
|
|||
|
e5c968933e
|
|||
|
166b07e91f
|
|||
|
836fe1adcd
|
|||
|
c5437ec7b3
|
|||
|
3fa0e6e4c9
|
|||
|
78f528ff77
|
|||
|
fbe13f8d49
|
|||
|
fae449acda
|
|||
|
099035a551
|
|||
|
8ed632dd8b
|
|||
|
fc8ab7a3bf
|
@@ -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
11
.gitignore
vendored
@@ -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
|
||||
|
||||
3541
Cargo.lock
generated
3541
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
37
Cargo.toml
37
Cargo.toml
@@ -1,25 +1,52 @@
|
||||
[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" ] }
|
||||
diesel_migrations = { version = "2.3.2", 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"
|
||||
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",
|
||||
]
|
||||
|
||||
# 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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX users_username_idx;
|
||||
DROP TABLE users;
|
||||
@@ -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);
|
||||
29
src/app.rs
Normal file
29
src/app.rs
Normal 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
19
src/build.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
53
src/main.rs
53
src/main.rs
@@ -1,48 +1,35 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod models;
|
||||
pub mod pages;
|
||||
pub mod util;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod schema;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
use crate::app::App;
|
||||
|
||||
#[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() {
|
||||
fn tracing_setup() {
|
||||
#[cfg(debug_assertions)]
|
||||
dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger");
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
server::main();
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
|
||||
pub mod user;
|
||||
|
||||
144
src/models/user.rs
Normal file
144
src/models/user.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Various user types. Some types marked server-only to help prevent
|
||||
//! leaking passwords to the frontend
|
||||
|
||||
/// Standard informational user type, contains no password information
|
||||
#[derive(Clone, Debug)]
|
||||
#[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
|
||||
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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Int4,
|
||||
username -> Varchar,
|
||||
hashed_password -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
91
src/server/auth.rs
Normal file
91
src/server/auth.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
|
||||
use crate::models::user::{DbUser, UserCredentials};
|
||||
use crate::server::database::{DbConn, DbPool};
|
||||
use crate::util::error::{Contextualize, Error, Result};
|
||||
|
||||
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 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")
|
||||
}
|
||||
162
src/server/config.rs
Normal file
162
src/server/config.rs
Normal 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
40
src/server/database.rs
Normal 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)
|
||||
}
|
||||
29
src/server/key_val_store.rs
Normal file
29
src/server/key_val_store.rs
Normal 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)
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
pub fn main() {
|
||||
#[cfg(feature = "server")]
|
||||
use dioxus::fullstack::axum::Router;
|
||||
|
||||
use crate::App;
|
||||
use crate::server::{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")?;
|
||||
|
||||
tracing::info!("Setup complete, returning Router...");
|
||||
Ok(dioxus::server::router(App))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod key_val_store;
|
||||
pub mod main;
|
||||
|
||||
pub use main::main;
|
||||
|
||||
333
src/util/error.rs
Normal file
333
src/util/error.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
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::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 {
|
||||
// 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}")))
|
||||
}
|
||||
}
|
||||
1
src/util/mod.rs
Normal file
1
src/util/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod error;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user