9.4 KiB · text History aecd85b
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"))
}