runegate 0.4.0

Lightweight Rust-based identity proxy
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use serde::{Deserialize, Serialize};
use std::env;
use tracing::{error, info, instrument};
use uuid::Uuid;

use crate::store::pg::PgStore;

#[derive(Debug, Deserialize)]
pub struct CreateInviteRequest {
    pub max_uses: i32,
}

#[derive(Serialize)]
pub struct InviteResponse {
    pub id: Uuid,
    pub code: String,
    pub max_uses: i32,
    pub is_revoked: bool,
}

fn check_admin_token(req: &HttpRequest) -> bool {
    let auth_header = match req.headers().get("Authorization") {
        Some(h) => match h.to_str() {
            Ok(s) => s,
            Err(_) => return false,
        },
        None => return false,
    };

    if !auth_header.starts_with("Bearer ") {
        return false;
    }

    let token = &auth_header[7..];
    let expected_token = match env::var("RUNEGATE_ADMIN_API_TOKEN") {
        Ok(t) => t,
        Err(_) => return false, // Admin API disabled if no token set
    };

    // Constant-time compare to prevent timing attacks
    use constant_time_eq::constant_time_eq;
    constant_time_eq(token.as_bytes(), expected_token.as_bytes())
}

#[instrument(name = "admin_create_invite", skip(req, pg_store))]
pub async fn create_invite(
    req: HttpRequest,
    req_body: web::Json<CreateInviteRequest>,
    pg_store: Option<web::Data<PgStore>>,
) -> impl Responder {
    if !check_admin_token(&req) {
        return HttpResponse::Unauthorized().json("Unauthorized admin access");
    }

    let Some(pg_store) = pg_store else {
        return HttpResponse::ServiceUnavailable()
            .json("Admin API requires a PostgreSQL backend (DATABASE_URL)");
    };

    // Generate a random 12-character alphanumeric code
    use rand::RngExt;
    let code: String = rand::rng()
        .sample_iter(&rand::distr::Alphanumeric)
        .take(12)
        .map(char::from)
        .collect();

    match pg_store.create_admin_invite(&code, req_body.max_uses).await {
        Ok(invite) => {
            info!("Created invite code: {}", invite.code);
            HttpResponse::Ok().json(InviteResponse {
                id: invite.id,
                code: invite.code,
                max_uses: invite.max_uses,
                is_revoked: invite.is_revoked,
            })
        }
        Err(e) => {
            error!("Failed to create invite: {}", e);
            HttpResponse::InternalServerError().json("Database error")
        }
    }
}

#[instrument(name = "admin_get_invites", skip(req, _pg_store))]
pub async fn get_invites(
    req: HttpRequest,
    _pg_store: Option<web::Data<PgStore>>,
) -> impl Responder {
    if !check_admin_token(&req) {
        return HttpResponse::Unauthorized().json("Unauthorized admin access");
    }

    // This is a placeholder since get_all_invites wasn't strictly required in the schema,
    // but the route exists. Returning Not Implemented for now.
    HttpResponse::NotImplemented().json("Get invites list not fully implemented yet")
}

#[instrument(name = "admin_revoke_invite", skip(req, pg_store))]
pub async fn revoke_invite(
    req: HttpRequest,
    path: web::Path<Uuid>,
    pg_store: Option<web::Data<PgStore>>,
) -> impl Responder {
    if !check_admin_token(&req) {
        return HttpResponse::Unauthorized().json("Unauthorized admin access");
    }

    let Some(pg_store) = pg_store else {
        return HttpResponse::ServiceUnavailable()
            .json("Admin API requires a PostgreSQL backend (DATABASE_URL)");
    };

    let invite_id = path.into_inner();

    match pg_store.revoke_invite(invite_id).await {
        Ok(_) => {
            info!("Revoked invite: {}", invite_id);
            HttpResponse::Ok().json("Invite revoked successfully")
        }
        Err(e) => {
            error!("Failed to revoke invite: {}", e);
            HttpResponse::InternalServerError().json("Database error")
        }
    }
}