use axum::{
extract::{Form, Query, State},
http::{header::SET_COOKIE, HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect},
};
use minijinja::context;
use sea_orm::*;
use serde::Deserialize;
use tracing::error;
use std::collections::HashMap;
use super::{internal, render, Fields};
use crate::access;
use crate::auth::{self, CreateUserRequest, LoginRequest};
use crate::entities::area_members::Role;
use crate::entities::{registration_token_areas, registration_tokens};
use crate::middleware::UserContext;
use crate::AppState;
/// Build the `Set-Cookie` value for a fresh session.
fn session_cookie(token: &str, duration_hours: i64) -> String {
format!(
"session_token={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
duration_hours * 3600
)
}
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
}
pub async fn show_login(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
render(&state, "auth/login.html", context! { title => "Login" })
}
#[derive(Debug, Deserialize)]
pub struct RegisterQuery {
pub token: Option<String>,
}
/// Show the registration page. When public registration is off (the default),
/// the page 404s unless an invite `?token=` is present.
pub async fn show_register(
State(state): State<AppState>,
Query(qs): Query<RegisterQuery>,
) -> Result<Html<String>, StatusCode> {
let invite = qs.token.as_deref().map(str::trim).filter(|s| !s.is_empty());
if invite.is_none() && !state.config.registration_enabled {
return Err(StatusCode::NOT_FOUND);
}
render(
&state,
"auth/register.html",
context! {
title => "Register",
invite_token => invite,
registration_open => state.config.registration_enabled,
},
)
}
#[derive(Debug, Deserialize)]
pub struct RegisterForm {
pub username: String,
pub email: String,
pub password: String,
pub confirm_password: String,
pub registration_token: String,
}
pub async fn handle_register(
State(state): State<AppState>,
Form(form): Form<RegisterForm>,
) -> Result<impl IntoResponse, StatusCode> {
if form.password != form.confirm_password {
return Err(StatusCode::BAD_REQUEST);
}
let token = form.registration_token.clone();
let user = auth::create_user(
&state.db,
CreateUserRequest {
username: form.username,
email: form.email,
password: form.password,
registration_token: form.registration_token,
},
)
.await
.map_err(|e| {
error!(error = %e, "registration failed");
StatusCode::BAD_REQUEST
})?;
// The invite may carry areas: registering through it accepts them.
apply_invite_areas(state.db.as_ref(), &token, user.id).await?;
let session = auth::create_session(&state.db, user.id, state.config.session_duration_hours)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
session_cookie(&session.session_token, state.config.session_duration_hours)
.parse()
.unwrap(),
);
Ok((headers, Redirect::to("/")))
}
pub async fn handle_login(
State(state): State<AppState>,
Form(form): Form<LoginForm>,
) -> Result<impl IntoResponse, StatusCode> {
let user = auth::authenticate_user(
&state.db,
LoginRequest { username: form.username, password: form.password },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
let session = auth::create_session(&state.db, user.id, state.config.session_duration_hours)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
session_cookie(&session.session_token, state.config.session_duration_hours)
.parse()
.unwrap(),
);
Ok((headers, Redirect::to("/")))
}
pub async fn handle_logout(
State(state): State<AppState>,
user: Option<UserContext>,
) -> impl IntoResponse {
if let Some(ctx) = user {
let _ = auth::delete_session(&state.db, &ctx.session.session_token).await;
}
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
"session_token=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".parse().unwrap(),
);
(headers, Redirect::to("/login"))
}
pub async fn show_profile(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
render(&state, "auth/profile.html", context! { title => "Profile", user => user.user })
}
/// List and create invite links (signed-in users only).
pub async fn show_invites(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
let invites = registration_tokens::Entity::find()
.filter(registration_tokens::Column::CreatedByUserId.eq(user.user.id))
.order_by_desc(registration_tokens::Column::CreatedAt)
.all(state.db.as_ref())
.await
.map_err(|e| {
error!(error = %e, "load invites failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let db = state.db.as_ref();
let admin_areas: Vec<minijinja::Value> = access::member_areas(db, user.user.id)
.await
.map_err(internal("load areas failed"))?
.into_iter()
.filter(|(_, r)| *r == Role::Admin)
.map(|(a, _)| context! { id => a.id, name => a.name })
.collect();
let mut grants: HashMap<uuid::Uuid, Vec<minijinja::Value>> = HashMap::new();
let area_names: HashMap<uuid::Uuid, String> = crate::entities::areas::Entity::find()
.all(db)
.await
.map_err(internal("load areas failed"))?
.into_iter()
.map(|a| (a.id, a.name))
.collect();
for g in registration_token_areas::Entity::find()
.filter(registration_token_areas::Column::TokenId.is_in(invites.iter().map(|i| i.id)))
.all(db)
.await
.map_err(internal("load invite areas failed"))?
{
grants
.entry(g.token_id)
.or_default()
.push(context! { name => area_names.get(&g.area_id), role => g.role });
}
let invites: Vec<minijinja::Value> = invites
.into_iter()
.map(|i| {
let areas = grants.remove(&i.id).unwrap_or_default();
context! { areas => areas, ..minijinja::Value::from_serialize(&i) }
})
.collect();
render(
&state,
"auth/invites.html",
context! {
title => "Invites",
user => user.user,
admin_areas => admin_areas,
roles => Role::ALL,
invites => invites,
now => chrono::Utc::now(),
},
)
}
async fn apply_invite_areas(db: &DatabaseConnection, token: &str, user_id: uuid::Uuid) -> Result<(), StatusCode> {
let Some(token) = registration_tokens::Entity::find()
.filter(registration_tokens::Column::Token.eq(token))
.one(db)
.await
.map_err(internal("load invite failed"))?
else {
return Ok(());
};
for grant in registration_token_areas::Entity::find()
.filter(registration_token_areas::Column::TokenId.eq(token.id))
.all(db)
.await
.map_err(internal("load invite areas failed"))?
{
access::grant(db, grant.area_id, user_id, grant.role).await.map_err(internal("grant area failed"))?;
}
Ok(())
}
/// Create a sign-up invite. Fields: `email`, `days`, and for each area to
/// grant, `area=<id>` with `role_<id>=voter|editor|admin`. Only areas the
/// inviter administers can be granted.
pub async fn handle_create_invite(
State(state): State<AppState>,
user: UserContext,
Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let fields = Fields(pairs);
let email = fields.one("email").map(str::to_string);
let days = fields
.one("days")
.and_then(|d| d.parse::<i64>().ok())
.filter(|d| *d > 0)
.unwrap_or(state.config.registration_token_duration_days);
let administered: Vec<uuid::Uuid> = access::member_areas(db, user.user.id)
.await
.map_err(internal("load areas failed"))?
.into_iter()
.filter(|(_, r)| *r == Role::Admin)
.map(|(a, _)| a.id)
.collect();
let grants: Vec<(uuid::Uuid, Role)> = fields
.uuids("area")
.into_iter()
.filter(|a| administered.contains(a))
.map(|a| (a, fields.one(&format!("role_{a}")).and_then(Role::parse).unwrap_or(Role::Voter)))
.collect();
let token = auth::create_registration_token(db, Some(user.user.id), email, days)
.await
.map_err(|e| {
error!(error = %e, "create invite failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !grants.is_empty() {
registration_token_areas::Entity::insert_many(grants.into_iter().map(|(area_id, role)| {
registration_token_areas::ActiveModel {
token_id: Set(token.id),
area_id: Set(area_id),
role: Set(role),
}
}))
.exec_without_returning(db)
.await
.map_err(internal("save invite areas failed"))?;
}
Ok(Redirect::to("/invites"))
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
9.4 KiB · text
History
7a00c02