gize-generator 0.8.2

Code generation engine for Gize: safe file writing and template rendering.
Documentation
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;

use validator::Validate;

use super::dto::{CreateUser, LoginRequest, TokenResponse, UpdateUser};
use super::error::Error;
use super::model::User;
use super::service;
use crate::auth::{hash_password, issue_token, verify_password};
use crate::state::AppState;

pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<User>>, Error> {
    Ok(Json(service::list(&state.db).await?))
}

pub async fn show(
    State(state): State<AppState>,
    Path(id): Path<uuid::Uuid>,
) -> Result<Json<User>, Error> {
    Ok(Json(service::find(&state.db, id).await?))
}

pub async fn create(
    State(state): State<AppState>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), Error> {
    input.validate()?;
    let stored = CreateUser {
        password: hash_password(&input.password).map_err(|_| Error::Internal)?,
        ..input
    };
    let user = service::create(&state.db, &stored).await?;
    Ok((StatusCode::CREATED, Json(user)))
}

pub async fn update(
    State(state): State<AppState>,
    Path(id): Path<uuid::Uuid>,
    Json(input): Json<UpdateUser>,
) -> Result<Json<User>, Error> {
    input.validate()?;
    let stored = UpdateUser {
        password: hash_password(&input.password).map_err(|_| Error::Internal)?,
        ..input
    };
    Ok(Json(service::update(&state.db, id, &stored).await?))
}

pub async fn delete(
    State(state): State<AppState>,
    Path(id): Path<uuid::Uuid>,
) -> Result<StatusCode, Error> {
    service::delete(&state.db, id).await?;
    Ok(StatusCode::NO_CONTENT)
}

/// Public: register a new user (hashing the password) and return a session token.
///
/// `is_admin` is forced to `false` here — this is a public endpoint, so a client must not be
/// able to grant itself admin. Admins are created through the guarded `POST /users` route.
pub async fn register(
    State(state): State<AppState>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<TokenResponse>), Error> {
    input.validate()?;
    let stored = CreateUser {
        password: hash_password(&input.password).map_err(|_| Error::Internal)?,
        is_admin: false,
        ..input
    };
    let user = service::create(&state.db, &stored).await?;
    let token = issue_token(&user.id).map_err(|_| Error::Internal)?;
    Ok((StatusCode::CREATED, Json(TokenResponse { token })))
}

/// Public: exchange email + password for a session token.
pub async fn login(
    State(state): State<AppState>,
    Json(input): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, Error> {
    input.validate()?;
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
        .bind(&input.email)
        .fetch_optional(&state.db)
        .await?;
    // Verify a password whether or not the email exists, so the response time does not reveal
    // which accounts are registered (user enumeration). An unknown email is checked against a
    // throwaway hash so it costs the same as a real verification.
    let authenticated = match &user {
        Some(user) => verify_password(&input.password, &user.password),
        None => {
            let _ = verify_password(&input.password, dummy_password_hash());
            false
        }
    };
    match (authenticated, user) {
        (true, Some(user)) => {
            let token = issue_token(&user.id).map_err(|_| Error::Internal)?;
            Ok(Json(TokenResponse { token }))
        }
        _ => Err(Error::Unauthorized),
    }
}

/// A valid Argon2 hash of a throwaway password, computed once. Used to equalize login timing for
/// unknown emails (see `login`), so an attacker cannot enumerate accounts by response time.
fn dummy_password_hash() -> &'static str {
    static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    HASH.get_or_init(|| hash_password("gize-timing-equalizer").unwrap_or_default())
}