diff --git a/Cargo.lock b/Cargo.lock index 052482a..bc7cbed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] diff --git a/flake.nix b/flake.nix index 7fc2d45..c1b79cf 100644 --- a/flake.nix +++ b/flake.nix @@ -70,6 +70,7 @@ wasm-bindgen-cli dart-sass binaryen + sqlx-cli ]; freebsd = buildTools.linux ++ [ pkgsCross.x86_64-freebsd.buildPackages.clang ]; @@ -89,9 +90,6 @@ # Cargo lint tools taplo cargo-deny - - # Web server tools - sqlx-cli ]; craneLib = (crane.mkLib pkgs).overrideToolchain (p: diff --git a/packages.nix b/packages.nix index ce8ec57..0a5fd90 100644 --- a/packages.nix +++ b/packages.nix @@ -92,6 +92,8 @@ let (craneLib.fileset.commonCargoSources ./sparse-server) ./sparse-server/style ./sparse-server/public + ./sparse-server/.sqlx + ./sparse-server/migrations ]; }; diff --git a/sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json b/sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json similarity index 50% rename from sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json rename to sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json index 5a9dfe8..03cd149 100644 --- a/sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json +++ b/sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT user_id, user_name FROM users", + "query": "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "name": "user_name", "ordinal": 1, "type_info": "Text" + }, + { + "name": "last_active", + "ordinal": 2, + "type_info": "Integer" } ], "parameters": { @@ -19,8 +24,9 @@ }, "nullable": [ false, - false + false, + true ] }, - "hash": "7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09" + "hash": "ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c" } diff --git a/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json b/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json new file mode 100644 index 0000000..a4e3276 --- /dev/null +++ b/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM users WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6" +} diff --git a/sparse-server/Cargo.toml b/sparse-server/Cargo.toml index 5f274fb..d4bf27d 100644 --- a/sparse-server/Cargo.toml +++ b/sparse-server/Cargo.toml @@ -28,10 +28,10 @@ tokio-stream = { version = "0.1", optional = true } futures-util = { version = "0.3", optional = true } tracing = { version = "0.1", optional = true } web-sys = { version = "0.3", features = ["WebSocket"] } -leptos-use = { version = "0.15", default-features = false, features = ["use_websocket"] } +leptos-use = { version = "0.15", default-features = false, features = ["use_websocket", "use_interval"] } codee = "0.2" sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true } -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } rpassword = { version = "7.3", optional = true } pbkdf2 = { version = "0.12", features = ["simple", "sha2"], optional = true } sha2 = { version = "0.10", optional = true } @@ -39,7 +39,7 @@ hex = { version = "0.4", optional = true } serde = "1.0" [features] -hydrate = ["leptos/hydrate"] +hydrate = ["leptos/hydrate", "chrono/wasmbind"] ssr = [ "dep:axum", "dep:tokio", diff --git a/sparse-server/src/app.rs b/sparse-server/src/app.rs index d89be7d..e276e92 100644 --- a/sparse-server/src/app.rs +++ b/sparse-server/src/app.rs @@ -1,7 +1,7 @@ use leptos::prelude::*; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_router::{ - components::{Route, Router, Routes}, + components::{A, Route, Router, Routes}, StaticSegment, }; @@ -50,12 +50,18 @@ pub fn App() -> impl IntoView { // content for this welcome page -
- - - - -
+ + + + + +
} } @@ -104,41 +110,43 @@ fn HomePage() -> impl IntoView { }; view! { -

"Welcome to Leptos!"

- - "Loading..."

} - > -

"Loaded time:"

- {move || { - loaded_time.get() - .map(|time| match time { - Ok(t) => view! {

{format!("{}", t)}

}, - Err(_) => view! {

{"Error!".to_string()}

} - }) - }} -

"Requested time:"

- - {move || pending.get().then_some("Loading...")} - {move || match requested_time.get() { - Some(t) => { - leptos::logging::log!("updating time display"); - view! {

{t}

} - }, - None => view! {

{"N/A".to_string()}

} - }} -
-

"Messages"

- - - {move || messages - .get() - .iter() - .map(|message| view! {

{message.clone()}

}) - .collect::>()} +
+

"Welcome to Leptos!"

+ + "Loading..."

} + > +

"Loaded time:"

+ {move || { + loaded_time.get() + .map(|time| match time { + Ok(t) => view! {

{format!("{}", t)}

}, + Err(_) => view! {

{"Error!".to_string()}

} + }) + }} +

"Requested time:"

+ + {move || pending.get().then_some("Loading...")} + {move || match requested_time.get() { + Some(t) => { + leptos::logging::log!("updating time display"); + view! {

{t}

} + }, + None => view! {

{"N/A".to_string()}

} + }} +
+

"Messages"

+ + + {move || messages + .get() + .iter() + .map(|message| view! {

{message.clone()}

}) + .collect::>()} +
} } diff --git a/sparse-server/src/cli/user.rs b/sparse-server/src/cli/user.rs index 8e033cb..16def65 100644 --- a/sparse-server/src/cli/user.rs +++ b/sparse-server/src/cli/user.rs @@ -24,30 +24,23 @@ async fn list_users(db: SqlitePool) -> anyhow::Result { Ok(ExitCode::SUCCESS) } -async fn create_user(db: SqlitePool, name: String) -> anyhow::Result { - let mut tx = db.begin().await?; +fn get_password() -> anyhow::Result { + let password1 = rpassword::prompt_password("Enter new password: ")?; + let password2 = rpassword::prompt_password("Enter password again: ")?; - let previous_user_check = sqlx::query_scalar!( - "SELECT COUNT(*) FROM users WHERE user_name = ?", - name - ) - .fetch_one(&mut *tx) - .await?; - - if previous_user_check > 0 { - eprintln!("Error! User already exists!"); - return Ok(ExitCode::FAILURE); + if password1 != password2 { + Err(anyhow::anyhow!("Passwords do not match!"))? } - let new_id = query!( - r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#, - name - ) - .execute(&mut *tx) - .await? - .last_insert_rowid(); + Ok(password1) +} - reset_password(&mut *tx, new_id as i16).await?; +async fn create_user(db: SqlitePool, name: String) -> anyhow::Result { + let password = get_password()?; + + let mut tx = db.begin().await?; + + crate::db::user::create_user(&mut tx, name, password).await?; tx.commit().await?; @@ -58,16 +51,11 @@ async fn create_user(db: SqlitePool, name: String) -> anyhow::Result { async fn reset_password<'a, E>(db: E, id: i16) -> anyhow::Result where - E: sqlx::Executor<'a, Database = sqlx::Sqlite> + E: sqlx::SqliteExecutor<'a> { - let password1 = rpassword::prompt_password("Enter new password: ")?; - let password2 = rpassword::prompt_password("Enter password again: ")?; + let password = get_password()?; - if password1 != password2 { - Err(anyhow::anyhow!("Passwords do not match!"))? - } - - crate::db::user::reset_password(db, id, password1).await?; + crate::db::user::reset_password(db, id, password).await?; println!("Password set successfully!"); diff --git a/sparse-server/src/db/user.rs b/sparse-server/src/db/user.rs index 44abd09..d3d5d62 100644 --- a/sparse-server/src/db/user.rs +++ b/sparse-server/src/db/user.rs @@ -1,12 +1,13 @@ -use sqlx::{sqlite::SqlitePool, Database}; use pbkdf2::{pbkdf2_hmac_array, password_hash::{rand_core::OsRng, SaltString}}; use sha2::Sha256; +use crate::error::Error; + const PASSWORD_ITERATIONS: u32 = 100_000; -pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> anyhow::Result<()> +pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> Result<(), crate::error::Error> where - E: sqlx::Executor<'a, Database = sqlx::Sqlite> + E: sqlx::SqliteExecutor<'a> { let salt = SaltString::generate(&mut OsRng); @@ -30,3 +31,37 @@ where Ok(()) } + +pub async fn create_user<'a, E>(acq: E, name: String, password: String) -> Result<(), crate::error::Error> +where + E: sqlx::Acquire<'a, Database = sqlx::Sqlite> +{ + let mut tx = acq.begin().await?; + + let previous_user_check = sqlx::query_scalar!( + "SELECT COUNT(*) FROM users WHERE user_name = ?", + name + ) + .fetch_one(&mut *tx) + .await?; + + if previous_user_check > 0 { + return Err(Error::UserCreate("User already exists".to_string())); + } + + tracing::info!("Creating new user {}", name); + + let new_id = sqlx::query!( + r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#, + name + ) + .execute(&mut *tx) + .await? + .last_insert_rowid(); + + reset_password(&mut *tx, new_id as i16, password).await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/sparse-server/src/error.rs b/sparse-server/src/error.rs new file mode 100644 index 0000000..9e5d010 --- /dev/null +++ b/sparse-server/src/error.rs @@ -0,0 +1,37 @@ +#[derive(Debug)] +pub enum Error { + UserCreate(String), + #[cfg(feature = "ssr")] + Sqlx(sqlx::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::UserCreate(err) => { + write!(f, "user create error: {err}") + } + #[cfg(feature = "ssr")] + Error::Sqlx(err) => { + write!(f, "sqlx error: {err:?}") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + #[cfg(feature = "ssr")] + Error::Sqlx(err) => Some(err), + _ => None, + } + } +} + +#[cfg(feature = "ssr")] +impl From for Error { + fn from(err: sqlx::Error) -> Self { + Self::Sqlx(err) + } +} diff --git a/sparse-server/src/lib.rs b/sparse-server/src/lib.rs index d5aa96b..422c291 100644 --- a/sparse-server/src/lib.rs +++ b/sparse-server/src/lib.rs @@ -2,6 +2,8 @@ pub mod app; pub mod users; +pub mod error; + pub mod db; #[cfg(feature = "hydrate")] diff --git a/sparse-server/src/main.rs b/sparse-server/src/main.rs index 6b8ad73..8bcd358 100644 --- a/sparse-server/src/main.rs +++ b/sparse-server/src/main.rs @@ -17,6 +17,8 @@ mod webserver; #[cfg(feature = "ssr")] pub mod users; +pub mod error; + pub mod db; #[cfg(feature = "ssr")] diff --git a/sparse-server/src/users.rs b/sparse-server/src/users.rs index a198954..5606430 100644 --- a/sparse-server/src/users.rs +++ b/sparse-server/src/users.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, offset::Utc}; use leptos::prelude::*; use serde::{Serialize, Deserialize}; #[cfg(feature = "ssr")] @@ -5,63 +6,334 @@ use { sqlx::SqlitePool }; +fn format_delta(time: chrono::TimeDelta) -> String { + let seconds = time.num_seconds(); + + match seconds { + 0..=59 => format!("{} second{} ago", seconds, if seconds == 1 {""} else {"s"}), + 60..=3599 => { + let minutes = seconds / 60; + format!("{} minute{} ago", minutes, if minutes == 1 {""} else {"s"}) + } + 3600..=86399 => { + let hours = seconds / 3600; + format!("{} hours{} ago", hours, if hours == 1 {""} else {"s"}) + } + _ => { + let days = seconds / 86400; + format!("{} day{} ago", days, if days == 1 {""} else {"s"}) + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct DbUser { + user_id: i64, + user_name: String, + last_active: Option +} + #[derive(Clone, Serialize, Deserialize)] pub struct PubUser { user_id: i64, - user_name: String + user_name: String, + last_active: Option> +} + +#[server] +async fn delete_user(user_id: i64) -> Result<(), ServerFnError> { + let pool = expect_context::(); + + sqlx::query!( + "DELETE FROM users WHERE user_id = ?", + user_id + ) + .execute(&pool) + .await?; + + Ok(()) +} + +#[server] +async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> { + let pool = expect_context::(); + + crate::db::user::reset_password(&pool, user_id as i16, password).await?; + + Ok(()) +} + +#[component] +pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView { + use leptos_use::{use_interval, UseIntervalReturn}; + + let UseIntervalReturn { counter, .. } = use_interval(1000); + let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active))); + + Effect::watch( + move || counter.get(), + move |_, _, _| { + set_time_ago(user.last_active.map(|active| format_delta(Utc::now() - active))); + }, + false + ); + + let dialog_ref = NodeRef::::new(); + let new_password = RwSignal::new("".to_owned()); + + let error_dialog_ref = NodeRef::::new(); + let (error_msg, set_error_msg) = signal(None::); + let clear_error = move |_| { + set_error_msg(None); + }; + + let handle_delete = Action::new(move |&id: &i64| async move { + match delete_user(id).await { + Ok(()) => { + refresh_user_list.dispatch(()); + } + Err(e) => { + set_error_msg(Some(e.to_string())); + } + } + }); + + let handle_reset = Action::new(move |info: &(i64, String)| { + let (id, password) = info.clone(); + + async move { + match reset_password(id, password).await { + Ok(()) => {} + Err(e) => { + set_error_msg(Some(e.to_string())); + } + } + } + }); + + view! { + + {error_msg} + + +
  • + {user.user_id} + ": " + {user.user_name} + {move || time_ago.get().map(|active| view! { + + "(last activity: " + {active} + ")" + + })} + + + + + +
    +
    + "Reset password" + + +
    +
    + +
    +
    +
    +
  • + } } #[server] async fn list_users() -> Result, ServerFnError> { - use leptos::server_fn::error::NoCustomError; + use futures::stream::StreamExt; let pool = expect_context::(); let users = sqlx::query_as!( - PubUser, - "SELECT user_id, user_name FROM users" + DbUser, + "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u" ) - .fetch_all(&pool) - .await?; + .fetch(&pool) + .map(|user| user.map(|u| PubUser { + user_id: u.user_id, + user_name: u.user_name, + last_active: u.last_active.map(|ts| DateTime::from_timestamp(ts, 0)).flatten() + })) + .collect::>>() + .await; - Ok(users) + let users: Result, _> = users.into_iter().collect(); + + Ok(users?) +} + +#[server] +async fn add_user(name: String, password: String) -> Result<(), ServerFnError> { + let pool = expect_context::(); + + crate::db::user::create_user(&pool, name, password).await?; + + Ok(()) } #[component] pub fn UserView() -> impl IntoView { let user_list = Resource::new(|| (), |_| async move { list_users().await }); - view! { -

    "User list"

    - "Loading..."

    } - > - "Errors loading users!"

    -
      - {move || errors.get() - .into_iter() - .map(|(_, e)| view! {
    • {e.to_string()}
    • }) - .collect::>()} -
    - } - > - {move || Suspend::new(async move { - let users_res = user_list.await; + let modal_ref = NodeRef::::new(); - users_res.map(|users| view! { + let new_user_name = RwSignal::new("".to_owned()); + let new_user_pass = RwSignal::new("".to_owned()); + let (user_add_error, set_user_add_error) = signal(None::); + let add_user_action = Action::new(move |_: &()| async move { + if let Some(modal) = modal_ref.get() { + let _ = modal.close(); + } + + match add_user(new_user_name.get(), new_user_pass.get()).await { + Ok(()) => { + user_list.refetch(); + } + Err(e) => { + set_user_add_error(Some(format!("Error adding user! {e:?}"))); + } + }; + + new_user_name.set("".to_owned()); + new_user_pass.set("".to_owned()); + }); + + let refresh_user_list = Action::new(move |_: &()| async move { + user_list.refetch(); + }); + + view! { +
    + + +
    +
    + "Add user" + + + + +
    +
    + +
    +
    +
    + {move || user_add_error.get().map(|err| view! { +

    + "Error adding user! " + {err} +

    + })} + +

    "User list"

    + "Loading..."

    } + > + "Errors loading users!"

      - {users + {move || errors.get() .into_iter() - .map(|user| view! { -
    • {user.user_id}": "{user.user_name}
    • - }) + .map(|(_, e)| view! {
    • {e.to_string()}
    • }) .collect::>()}
    - }) - })} -
    -
    + } + > + {move || Suspend::new(async move { + let users_res = user_list.await; + + users_res.map(|users| view! { +
      + {users + .into_iter() + .map(|user| view! { + + }) + .collect::>()} +
    + }) + })} + + +
    } } diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs index adea3d4..242df5f 100644 --- a/sparse-server/src/webserver.rs +++ b/sparse-server/src/webserver.rs @@ -9,6 +9,7 @@ use sparse_server::app::*; pub async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> axum::response::Response { + tracing::info!("Handling websocket request to /ws"); ws.on_upgrade(handle_websocket) } diff --git a/sparse-server/style/_main.scss b/sparse-server/style/_main.scss new file mode 100644 index 0000000..9f02515 --- /dev/null +++ b/sparse-server/style/_main.scss @@ -0,0 +1,3 @@ +main.main { + +} diff --git a/sparse-server/style/_users.scss b/sparse-server/style/_users.scss new file mode 100644 index 0000000..5ba6433 --- /dev/null +++ b/sparse-server/style/_users.scss @@ -0,0 +1,24 @@ +main.users { + dialog::backdrop { + background-color: #0008; + } + + form fieldset { + display: grid; + grid-template-columns: 1fr 1fr; + + * { + display: inline-block; + margin: 10px; + } + } + + ul { + list-style-type: none; + } + + li button { + margin-left: 10px; + margin-right: 0px; + } +} diff --git a/sparse-server/style/main.scss b/sparse-server/style/main.scss index e4538e1..967db27 100644 --- a/sparse-server/style/main.scss +++ b/sparse-server/style/main.scss @@ -1,4 +1,80 @@ -body { +@use '_users'; +@use '_main'; + +html, body { font-family: sans-serif; - text-align: center; -} \ No newline at end of file + background-color: #201f30; + + color: white; + + margin: 0; + padding: 0; + width: 100%; + height: 100%; +} + +body { + display: grid; + grid-template-columns: 350px 1fr; + grid-template-rows: 79px 1fr; + grid-template-areas: + "nav nav" + "beacons main"; +} + +nav { + background-color: #11111c; + grid-area: nav; + border-bottom: 1px solid #2e2e59; + + h1 { + color: white; + text-decoration: none; + display: inline-block; + padding: 15px; + margin: 10px 5px; + } + + a, a:visited { + color: white; + text-decoration: none; + display: inline-block; + padding: 10px; + margin: 10px 5px; + } + + a:hover { + text-decoration: underline; + } +} + +aside.beacons { + grid-area: beacons; + background-color: #11111c; + overflow-y: auto; + overflow-x: hidden; +} + +main { + grid-area: main; + + padding: 20px; + + overflow: auto; +} + +input[type="submit"], +input[type="button"], +button { + background-color: #fff; + border: 1px solid transparent; + cursor: pointer; + margin: 5px; + padding: 2px 4px; + border-radius: 2px; + box-shadow: #fff7 0 1px 0 inset; + + &:hover { + background-color: #ccc; + } +}