feat: got sessions working
This commit is contained in:
@@ -2,6 +2,7 @@ use leptos::prelude::*;
|
||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||
use leptos_router::{
|
||||
components::{A, Route, Router, Routes},
|
||||
hooks::use_query_map,
|
||||
path
|
||||
};
|
||||
use serde::{Serialize, Deserialize};
|
||||
@@ -21,56 +22,38 @@ pub async fn test_retrieve() -> Result<u64, ServerFnError> {
|
||||
Ok(since_the_epoch)
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
user_id: i64,
|
||||
user_name: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn me() -> Result<Option<User>, ServerFnError> {
|
||||
let session: crate::db::user::AuthSession = leptos_axum::extract().await?;
|
||||
pub async fn me() -> Result<Option<User>, ServerFnError> {
|
||||
tracing::info!("I'm being checked!");
|
||||
|
||||
Ok(session.user.map(|user| User {
|
||||
let user = crate::db::user::get_auth_session().await?;
|
||||
|
||||
tracing::debug!("User returned: {:?}", user);
|
||||
|
||||
Ok(user.map(|user| User {
|
||||
user_id: user.user_id,
|
||||
user_name: user.user_name
|
||||
}))
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn login(username: String, password: String, next: Option<String>) -> Result<(), ServerFnError> {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
async fn login(username: String, password: String, next: String) -> Result<(), ServerFnError> {
|
||||
crate::db::user::create_auth_session(username, password).await?;
|
||||
|
||||
let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?;
|
||||
|
||||
let user = match session.authenticate((username, password).clone()).await {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => return Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string())),
|
||||
Err(e) => return Err(server_fn::server_fn_error!(e).into())
|
||||
};
|
||||
|
||||
if let Err(e) = session.login(&user).await {
|
||||
return Err(server_fn::server_fn_error!(e).into());
|
||||
}
|
||||
|
||||
if let Some(target) = next {
|
||||
leptos_axum::redirect(&target);
|
||||
}
|
||||
leptos_axum::redirect(&next);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn logout() -> Result<(), ServerFnError> {
|
||||
let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?;
|
||||
|
||||
match session.logout().await {
|
||||
Ok(_) => {
|
||||
leptos_axum::redirect("/login");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(server_fn::server_fn_error!(e).into())
|
||||
}
|
||||
crate::db::user::destroy_auth_session().await
|
||||
}
|
||||
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
@@ -95,12 +78,38 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
let user = Resource::new(|| (), |_| async { me().await });
|
||||
let login = ServerAction::<Login>::new();
|
||||
|
||||
let (user_res, set_user_res) = signal(None);
|
||||
|
||||
let user = Resource::new(
|
||||
move || {
|
||||
login.version().get()
|
||||
},
|
||||
|_| async {
|
||||
#[cfg(feature = "ssr")]
|
||||
tracing::info!("Checking user account");
|
||||
#[cfg(feature = "hydrate")]
|
||||
leptos::logging::log!("Checking user account");
|
||||
|
||||
me().await
|
||||
}
|
||||
);
|
||||
|
||||
Effect::new(move || {
|
||||
let u = user.get();
|
||||
set_user_res(u.map(|u2| u2.ok()).flatten().flatten());
|
||||
});
|
||||
|
||||
provide_context(user_res);
|
||||
|
||||
Effect::new(move || {
|
||||
leptos::logging::log!("User resource: {:?}", user.get());
|
||||
});
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/sparse-server.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="Sparse Control"/>
|
||||
|
||||
<Router>
|
||||
@@ -108,35 +117,35 @@ pub fn App() -> impl IntoView {
|
||||
<h1>"Sparse control"</h1>
|
||||
<A href="/">"Home"</A>
|
||||
<Suspense fallback=|| ()>
|
||||
<A href="/beacons">"Beacon management"</A>
|
||||
<A href="/users">"Users"</A>
|
||||
{move || user
|
||||
.get()
|
||||
.map(|err| err.ok())
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|_| view! {
|
||||
<a
|
||||
href="#"
|
||||
on:click=move |_| {
|
||||
leptos::task::spawn_local(async move {
|
||||
let _ = logout().await;
|
||||
user.refetch();
|
||||
});
|
||||
}
|
||||
>
|
||||
"Log out"
|
||||
</a>
|
||||
})}
|
||||
{move || user
|
||||
.get()
|
||||
.map(|err| err.ok())
|
||||
.flatten()
|
||||
.flatten()
|
||||
.is_none()
|
||||
.then(|| view! {
|
||||
<A href="/login">"Log in"</A>
|
||||
})}
|
||||
{move || user
|
||||
.get()
|
||||
.map(|err| err.ok())
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|_| view! {
|
||||
<A href="/beacons">"Beacon management"</A>
|
||||
<A href="/users">"Users"</A>
|
||||
<a
|
||||
href="#"
|
||||
on:click=move |_| {
|
||||
leptos::task::spawn_local(async move {
|
||||
let _ = logout().await;
|
||||
user.refetch();
|
||||
});
|
||||
}
|
||||
>
|
||||
"Log out"
|
||||
</a>
|
||||
})}
|
||||
{move || user
|
||||
.get()
|
||||
.map(|err| err.ok())
|
||||
.flatten()
|
||||
.flatten()
|
||||
.is_none()
|
||||
.then(|| view! {
|
||||
<A href="/login">"Log in"</A>
|
||||
})}
|
||||
</Suspense>
|
||||
</nav>
|
||||
|
||||
@@ -145,7 +154,7 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
<Routes fallback=|| "Page not found.".into_view()>
|
||||
<Route path=path!("users") view=crate::users::UserView />
|
||||
<Route path=path!("login") view=move || view! { <LoginPage /> } />
|
||||
<Route path=path!("login") view=move || view! { <LoginPage login/> }/>
|
||||
<Route path=path!("") view=HomePage/>
|
||||
</Routes>
|
||||
</Router>
|
||||
@@ -153,98 +162,40 @@ pub fn App() -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LoginPage() -> impl IntoView {
|
||||
fn LoginPage(login: ServerAction<Login>) -> impl IntoView {
|
||||
let next = move || use_query_map().read().get("next").unwrap_or("/".to_string());
|
||||
|
||||
view! {
|
||||
<main class="login">
|
||||
{move || match login.value().get() {
|
||||
Some(Ok(_)) => None,
|
||||
None => None,
|
||||
Some(Err(e)) => Some(view! {
|
||||
<div>
|
||||
"Error signing in: "
|
||||
{format!("{e:?}")}
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
<ActionForm action=login>
|
||||
<label>"Username"</label>
|
||||
<input type="text" name="username" />
|
||||
<label>"Password"</label>
|
||||
<input type="password" name="password" />
|
||||
<div></div>
|
||||
<input type="submit" value="Submit" />
|
||||
<input type="hidden" value=next name="next" />
|
||||
</ActionForm>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
fn HomePage() -> impl IntoView {
|
||||
|
||||
// Creates a reactive value to update the button
|
||||
let count = RwSignal::new(0);
|
||||
let on_click = move |_| *count.write() += 1;
|
||||
|
||||
let loaded_time = Resource::new(|| (), |_| async move { test_retrieve().await });
|
||||
|
||||
let (requested_time, set_requested_time) = signal(None::<String>);
|
||||
let request_time = Action::new(move |_: &()| async move {
|
||||
let t = match test_retrieve().await {
|
||||
Ok(t) => format!("{}", t),
|
||||
Err(_) => "Error!".to_string()
|
||||
};
|
||||
set_requested_time(Some(t));
|
||||
});
|
||||
let request_time_callback = move |_| {
|
||||
request_time.dispatch(());
|
||||
};
|
||||
let pending = request_time.pending();
|
||||
|
||||
let text_input = RwSignal::new("".to_owned());
|
||||
#[cfg_attr(feature = "ssr", allow(unused_variables))]
|
||||
let (messages, set_messages) = signal(Vec::<String>::new());
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
use leptos_use::{UseWebSocketReturn, use_websocket};
|
||||
|
||||
let UseWebSocketReturn { send, message, .. } = use_websocket::<String, String, codee::string::FromToStringCodec>("/ws");
|
||||
|
||||
Effect::new(move |_| {
|
||||
message.with(move |message| {
|
||||
if let Some(m) = message {
|
||||
leptos::logging::log!("got update: {}", m);
|
||||
set_messages.update(|messages: &mut Vec<_>| messages.push(format!("msg: {}", m)));
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let send_message = move |_| {
|
||||
send(&text_input.get());
|
||||
text_input.set("".to_string());
|
||||
};
|
||||
} else {
|
||||
let send_message = move |_| {};
|
||||
}
|
||||
}
|
||||
|
||||
view! {
|
||||
<main class="main">
|
||||
<h1>"Welcome to Leptos!"</h1>
|
||||
<button on:click=on_click>"Click Me: " {count}</button>
|
||||
<Suspense
|
||||
fallback=move || view! { <p>"Loading..."</p> }
|
||||
>
|
||||
<h2>"Loaded time:"</h2>
|
||||
{move || {
|
||||
loaded_time.get()
|
||||
.map(|time| match time {
|
||||
Ok(t) => view! { <p>{format!("{}", t)}</p>},
|
||||
Err(_) => view! { <p>{"Error!".to_string()}</p> }
|
||||
})
|
||||
}}
|
||||
<h2>"Requested time:"</h2>
|
||||
<button on:click=request_time_callback>"Request new time"</button>
|
||||
{move || pending.get().then_some("Loading...")}
|
||||
{move || match requested_time.get() {
|
||||
Some(t) => {
|
||||
leptos::logging::log!("updating time display");
|
||||
view! { <p>{t}</p> }
|
||||
},
|
||||
None => view! { <p>{"N/A".to_string()}</p> }
|
||||
}}
|
||||
</Suspense>
|
||||
<h2>"Messages"</h2>
|
||||
<input bind:value=text_input />
|
||||
<input
|
||||
on:click=send_message
|
||||
type="button"
|
||||
value="Send message"
|
||||
/>
|
||||
{move || messages
|
||||
.get()
|
||||
.iter()
|
||||
.map(|message| view! { <p>{message.clone()}</p> })
|
||||
.collect::<Vec<_>>()}
|
||||
<h1>"Welcome to sparse!"</h1>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
use leptos::{prelude::expect_context, server_fn::error::NoCustomError};
|
||||
use leptos_axum::{extract, ResponseOptions};
|
||||
use leptos::prelude::ServerFnError;
|
||||
use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::{OsRng, RngCore}, SaltString}};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct User {
|
||||
pub user_id: i64,
|
||||
@@ -6,13 +14,6 @@ pub struct User {
|
||||
pub last_active: Option<i64>
|
||||
}
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::OsRng, SaltString}};
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
impl std::fmt::Debug for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("User")
|
||||
@@ -23,27 +24,16 @@ impl std::fmt::Debug for User {
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i64;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.user_id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.password_hash.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
async fn hash_password(pass: &[u8]) -> Result<String, Error> {
|
||||
Ok(tokio::task::spawn_blocking({
|
||||
let pass = pass.to_owned();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
move || Pbkdf2.hash_password(
|
||||
&*pass,
|
||||
&salt,
|
||||
).map(|hash| hash.to_string())
|
||||
move ||
|
||||
Pbkdf2.hash_password(
|
||||
&*pass,
|
||||
&salt,
|
||||
).map(|hash| hash.serialize().as_str().to_string())
|
||||
}).await??)
|
||||
}
|
||||
|
||||
@@ -114,67 +104,149 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Backend(SqlitePool);
|
||||
const SESSION_ID_KEY: &'static str = "session_id";
|
||||
const SESSION_AGE: i64 = 30 * 60;
|
||||
|
||||
impl Backend {
|
||||
pub fn new(db: SqlitePool) -> Self {
|
||||
Self(db)
|
||||
}
|
||||
}
|
||||
pub async fn create_auth_session(username: String, password: String) -> Result<(), ServerFnError> {
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use axum::http::{header, HeaderValue};
|
||||
|
||||
#[async_trait]
|
||||
impl AuthnBackend for Backend {
|
||||
type User = User;
|
||||
type Credentials = (String, String);
|
||||
type Error = Error;
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let resp = expect_context::<ResponseOptions>();
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user: Option<Self::User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_name = ?",
|
||||
creds.0
|
||||
let user: Option<User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_name = ?",
|
||||
username
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let Some(user) = user else {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string()));
|
||||
};
|
||||
|
||||
let good_hash = verify_password(
|
||||
&password,
|
||||
&user.password_hash
|
||||
).await?;
|
||||
|
||||
if good_hash {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires = now + SESSION_AGE;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ?",
|
||||
now
|
||||
)
|
||||
.fetch_optional(&self.0)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let Some(user) = user else { return Ok(None); };
|
||||
let session_id: String = tokio::task::spawn_blocking(|| {
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
hex::encode(&key[..])
|
||||
}).await?;
|
||||
|
||||
let good_hash = verify_password(
|
||||
&user.password_hash,
|
||||
&creds.1
|
||||
).await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO sessions (session_id, user_id, expires) VALUES (?, ?, ?)",
|
||||
session_id,
|
||||
user.user_id,
|
||||
expires
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
if good_hash {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let cookie = Cookie::build((SESSION_ID_KEY, &session_id))
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ?",
|
||||
now
|
||||
)
|
||||
.execute(&self.0)
|
||||
.await?;
|
||||
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
|
||||
resp.insert_header(header::SET_COOKIE, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user: Option<Self::User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_id = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&self.0)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthSession = axum_login::AuthSession<Backend>;
|
||||
pub async fn destroy_auth_session() -> Result<(), ServerFnError> {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let jar = extract::<CookieJar>().await?;
|
||||
|
||||
let Some(cookie) = jar.get(SESSION_ID_KEY) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM sessions WHERE session_id = ?",
|
||||
session_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_session() -> Result<Option<User>, ServerFnError> {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let jar = extract::<CookieJar>().await?;
|
||||
|
||||
let Some(cookie) = jar.get(SESSION_ID_KEY) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let session_id = cookie.value();
|
||||
|
||||
let user = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT users.user_id, user_name, password_hash, last_active \
|
||||
FROM users \
|
||||
INNER JOIN sessions \
|
||||
WHERE session_id = ? \
|
||||
AND expires > ?",
|
||||
session_id,
|
||||
now
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(u) = &user {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires = now + SESSION_AGE;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ? WHERE user_id = ?",
|
||||
now,
|
||||
u.user_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE sessions SET expires = ? WHERE session_id = ?",
|
||||
expires,
|
||||
session_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM sessions WHERE expires < ?",
|
||||
now
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<std::process::ExitCode> {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),
|
||||
.unwrap_or_else(|_| format!("{}=debug,tower_http=trace", env!("CARGO_CRATE_NAME")).into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
@@ -3,7 +3,9 @@ use leptos::prelude::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
#[cfg(feature = "ssr")]
|
||||
use {
|
||||
sqlx::SqlitePool
|
||||
sqlx::SqlitePool,
|
||||
leptos::server_fn::error::NoCustomError,
|
||||
crate::db::user
|
||||
};
|
||||
|
||||
fn format_delta(time: chrono::TimeDelta) -> String {
|
||||
@@ -42,6 +44,12 @@ pub struct PubUser {
|
||||
|
||||
#[server]
|
||||
async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
|
||||
}
|
||||
|
||||
let pool = expect_context::<SqlitePool>();
|
||||
|
||||
sqlx::query!(
|
||||
@@ -56,6 +64,12 @@ async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
|
||||
|
||||
#[server]
|
||||
async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
|
||||
}
|
||||
|
||||
let pool = expect_context::<SqlitePool>();
|
||||
|
||||
crate::db::user::reset_password(&pool, user_id as i16, password).await?;
|
||||
@@ -67,7 +81,9 @@ async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnEr
|
||||
pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView {
|
||||
use leptos_use::{use_interval, UseIntervalReturn};
|
||||
|
||||
#[cfg_attr(feature = "ssr", allow(unused_variables))]
|
||||
let UseIntervalReturn { counter, .. } = use_interval(1000);
|
||||
#[cfg_attr(feature = "ssr", allow(unused_variables))]
|
||||
let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active)));
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
@@ -126,7 +142,7 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into
|
||||
{user.user_name}
|
||||
{move || time_ago.get().map(|active| view! {
|
||||
<span>
|
||||
"(last activity: "
|
||||
" (last activity: "
|
||||
{active}
|
||||
")"
|
||||
</span>
|
||||
@@ -189,6 +205,12 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into
|
||||
|
||||
#[server]
|
||||
async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
|
||||
}
|
||||
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let pool = expect_context::<SqlitePool>();
|
||||
@@ -213,6 +235,12 @@ async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
|
||||
|
||||
#[server]
|
||||
async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned()));
|
||||
}
|
||||
|
||||
let pool = expect_context::<SqlitePool>();
|
||||
|
||||
crate::db::user::create_user(&pool, name, password).await?;
|
||||
@@ -222,6 +250,15 @@ async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
|
||||
|
||||
#[component]
|
||||
pub fn UserView() -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
Effect::new(move || {
|
||||
let user = expect_context::<ReadSignal<Option<crate::app::User>>>();
|
||||
if user.get().is_none() {
|
||||
let navigate = leptos_router::hooks::use_navigate();
|
||||
navigate("/login?next=/users", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
let user_list = Resource::new(|| (), |_| async move { list_users().await });
|
||||
|
||||
let modal_ref = NodeRef::<leptos::html::Dialog>::new();
|
||||
|
||||
@@ -4,68 +4,15 @@ use sqlx::sqlite::SqlitePool;
|
||||
use axum::Router;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use tokio::{signal, task::AbortHandle};
|
||||
use tower_sessions::{Expiry, SessionManagerLayer, session_store::ExpiredDeletion};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tokio::signal;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async fn handle_websocket(mut socket: axum::extract::ws::WebSocket) {
|
||||
use tracing::info;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = socket.recv() => {
|
||||
match msg {
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
Some(msg) => {
|
||||
let Ok(axum::extract::ws::Message::Text(msg)) = msg else { continue; };
|
||||
|
||||
info!("Received message! {}", msg);
|
||||
|
||||
let Ok(_) = socket.send(axum::extract::ws::Message::Text(msg)).await else { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {
|
||||
let Ok(_) = socket.send(axum::extract::ws::Message::Text(format!("{}", count))).await else { break; };
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAddrV4, db: SqlitePool) -> anyhow::Result<ExitCode> {
|
||||
let conf = get_configuration(None).unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let session_store = SqliteStore::new(db.clone());
|
||||
session_store.migrate().await?;
|
||||
|
||||
let deletion_task = tokio::task::spawn(
|
||||
session_store
|
||||
.clone()
|
||||
.continuously_delete_expired(tokio::time::Duration::from_secs(60))
|
||||
);
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_expiry(Expiry::OnInactivity(time::Duration::minutes(20)));
|
||||
|
||||
|
||||
let backend = crate::db::user::Backend::new(db.clone());
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
|
||||
let compression_layer = tower_http::compression::CompressionLayer::new()
|
||||
.gzip(true)
|
||||
.deflate(true)
|
||||
@@ -73,7 +20,6 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd
|
||||
.zstd(true);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", axum::routing::any(websocket))
|
||||
.leptos_routes_with_context(
|
||||
&leptos_options,
|
||||
routes,
|
||||
@@ -81,11 +27,15 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd
|
||||
{
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
}
|
||||
)
|
||||
.fallback(leptos_axum::file_and_error_handler::<leptos::config::LeptosOptions, _>(shell))
|
||||
.with_state(leptos_options)
|
||||
.layer(auth_layer)
|
||||
.layer(compression_layer);
|
||||
.layer(
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.layer(compression_layer)
|
||||
);
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
@@ -93,15 +43,13 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd
|
||||
tracing::info!("management interface listening on http://{}", &management_address);
|
||||
|
||||
axum::serve(management_listener, app.into_make_service())
|
||||
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle()))
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
deletion_task.await??;
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
@@ -122,11 +70,9 @@ async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {
|
||||
tracing::info!("Received Ctrl-C");
|
||||
deletion_task_abort_handle.abort()
|
||||
},
|
||||
_ = terminate => {
|
||||
tracing::info!("Received terminate command");
|
||||
deletion_task_abort_handle.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user