wip: signup links

signup-links
Jef Roosens 2025-08-28 11:50:01 +02:00
parent 97b30b1840
commit 23ce6866c3
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
5 changed files with 141 additions and 37 deletions

View File

@ -30,6 +30,52 @@ pub fn router(ctx: Context) -> Router<Context> {
// loop
.route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout))
.route("/signup", get(get_signup))
}
/// Middleware that authenticates the current user via the session token. If the credentials are
/// invalid, the user is redirected to the login page.
pub async fn auth_web_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/login");
match extract_session(ctx, &jar).await {
Ok(Some(session)) => {
req.extensions_mut().insert(session);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
}
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(super::SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
} else {
Ok(None)
}
}
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
@ -103,47 +149,64 @@ async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<im
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
}
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(super::SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
#[derive(Deserialize)]
struct SignupForm {
username: String,
password: String,
password_confirm: String,
}
Ok(session)
struct SignupValidation {
username_available: bool,
passwords_match: bool,
}
impl SignupForm {
fn validate(&self, ctx: &Context) -> AppResult<SignupValidation> {
let username_available = match ctx.store.get_user(&self.username) {
Ok(_) => false,
Err(AuthErr::UnknownUser) => true,
Err(err) => {
return Err(err.into());
}
};
let passwords_match = self.password == self.password_confirm;
Ok(SignupValidation {
username_available,
passwords_match,
})
}
}
impl SignupValidation {
pub fn valid(&self) -> bool {
self.username_available && self.passwords_match
}
}
async fn get_signup(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
if extract_session(ctx.clone(), &jar)
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
.ok()
.flatten()
.is_some()
{
Redirect::to("/").into_response()
} else {
Ok(None)
}
}
/// Middleware that authenticates the current user via the session token. If the credentials are
/// invalid, the user is redirected to the login page.
pub async fn auth_web_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/login");
match extract_session(ctx, &jar).await {
Ok(Some(session)) => {
req.extensions_mut().insert(session);
next.run(req).await
View::Signup {
username_available: true,
passwords_match: true,
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
async fn post_signup(
State(ctx): State<Context>,
Form(signup): Form<SignupForm>,
) -> AppResult<Response> {
todo!()
}

View File

@ -89,6 +89,14 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
View::Users(Vec::new(), 0, None).template(),
include_str!("templates/views/users.html"),
),
(
View::Signup {
username_available: true,
passwords_match: true,
}
.template(),
include_str!("templates/views/signup.html"),
),
])?;
Ok(tera)

View File

@ -0,0 +1,11 @@
<article>
<form hx-post hx-target="#inner">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<label for="password_confirm">Confirm password:</label>
<input type="password" id="password_confirm" name="password_confirm">
<input type="submit" value="Sign Up">
</form>
</article>

View File

@ -12,11 +12,21 @@
<table id="users">
<thead>
<th>Username</th>
<th>Action</th>
</thead>
<tbody>
{%- for user in users %}
<tr>
<th>{{ user.username }}</th>
<th>
{%- if user.id != current_user_id -%}
<a hx-delete="/users/{{ user.id }}"
hx-target="closest tr"
>Remove</a>
{%- else -%}
Current user
{%- endif -%}
</th>
</tr>
{% endfor -%}

View File

@ -8,6 +8,10 @@ pub enum View {
Login,
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
Users(Vec<gpodder::User>, i64, Option<Query>),
Signup {
username_available: bool,
passwords_match: bool,
},
}
#[derive(Serialize)]
@ -30,6 +34,7 @@ impl Template for View {
Self::Login => "views/login.html",
Self::Sessions(..) => "views/sessions.html",
Self::Users(..) => "views/users.html",
Self::Signup { .. } => "views/signup.html",
}
}
@ -61,6 +66,13 @@ impl Template for View {
ctx.insert("next_page_query", &query.encode());
}
}
Self::Signup {
username_available,
passwords_match,
} => {
ctx.insert("username_available", &username_available);
ctx.insert("passwords_match", &passwords_match);
}
_ => {}
};