5.9 KiB · text History d76cd01
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 super::render;
use crate::auth::{self, CreateUserRequest, LoginRequest};
use crate::entities::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 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
    })?;

    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
        })?;

    render(
        &state,
        "auth/invites.html",
        context! {
            title => "Invites",
            user => user.user,
            invites => invites,
            now => chrono::Utc::now(),
        },
    )
}

#[derive(Debug, Deserialize)]
pub struct CreateInviteForm {
    pub email: Option<String>,
    pub days: Option<i64>,
}

pub async fn handle_create_invite(
    State(state): State<AppState>,
    user: UserContext,
    Form(form): Form<CreateInviteForm>,
) -> Result<Redirect, StatusCode> {
    let email = form.email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
    let days = form
        .days
        .filter(|d| *d > 0)
        .unwrap_or(state.config.registration_token_duration_days);

    auth::create_registration_token(state.db.as_ref(), Some(user.user.id), email, days)
        .await
        .map_err(|e| {
            error!(error = %e, "create invite failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
    Ok(Redirect::to("/invites"))
}