//! Git personal-access-token management API (for git over HTTPS). //! //! The plaintext token is returned exactly once — in the `create` response — //! and never again; only its hash is stored. List/revoke never expose it. use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::Form; use serde::Deserialize; use crate::auth::AuthUser; use crate::db::{self, GitAccessTokenId}; use crate::error::{AppError, Result}; use crate::helpers::{hx_toast, is_htmx_request}; use crate::AppState; #[derive(Debug, Deserialize)] pub struct CreateTokenRequest { pub name: String, /// HTML checkbox: present (`"on"`) when checked, absent otherwise. #[serde(default)] pub can_push: Option, /// Optional `YYYY-MM-DD` expiry; empty means no expiry. #[serde(default)] pub expires_on: String, } /// GET /api/users/me/git-tokens/list: HTMX partial listing the user's tokens. #[tracing::instrument(skip_all, name = "git_tokens::list_html")] pub(super) async fn list_html( State(state): State, AuthUser(user): AuthUser, ) -> Result { let html = render_list(&state, user.id, None).await?; Ok(axum::response::Html(html)) } /// POST /api/users/me/git-tokens: mint a new token. Returns the plaintext once. #[tracing::instrument(skip_all, name = "git_tokens::create")] pub(super) async fn create( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let name = req.name.trim(); if name.is_empty() || name.len() > 128 { return Err(AppError::validation("Token name must be 1-128 characters".to_string())); } let can_push = req.can_push.is_some(); // Parse optional expiry (end of the given day, UTC). Reject past dates. let expires_at = if req.expires_on.trim().is_empty() { None } else { let date = chrono::NaiveDate::parse_from_str(req.expires_on.trim(), "%Y-%m-%d") .map_err(|_| AppError::validation("Invalid expiry date".to_string()))?; let dt = date .and_hms_opt(23, 59, 59) .ok_or_else(|| AppError::validation("Invalid expiry date".to_string()))? .and_utc(); if dt <= chrono::Utc::now() { return Err(AppError::validation("Expiry date must be in the future".to_string())); } Some(dt) }; let (plaintext, hash) = crate::crypto::generate_git_token(); db::git_access_tokens::create(&state.db, user.id, name, &hash, can_push, expires_at).await?; if is_htmx_request(&headers) { let html = render_list(&state, user.id, Some(&plaintext)).await?; return Ok(( [("HX-Trigger", hx_toast("Access token created", "success"))], axum::response::Html(html), ) .into_response()); } // Non-HTMX: the plaintext is the whole point, so return it as plain text. Ok(plaintext.into_response()) } /// DELETE /api/users/me/git-tokens/{id}: revoke a token. #[tracing::instrument(skip_all, name = "git_tokens::revoke")] pub(super) async fn revoke( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; if !db::git_access_tokens::revoke(&state.db, id, user.id).await? { return Err(AppError::NotFound); } if is_htmx_request(&headers) { let html = render_list(&state, user.id, None).await?; return Ok(( [("HX-Trigger", hx_toast("Access token revoked", "success"))], axum::response::Html(html), ) .into_response()); } Ok(axum::http::StatusCode::NO_CONTENT.into_response()) } /// Render the tokens list partial, optionally with a freshly-minted plaintext /// shown once at the top. async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result { use askama::Template; let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?; let tokens: Vec = tokens.iter().map(GitTokenView::from).collect(); Ok(crate::templates::GitTokensListTemplate { tokens, new_token: new_token.map(str::to_string), } .render() .unwrap_or_default()) } /// View type for token display (never carries the hash or plaintext). #[derive(Clone)] pub struct GitTokenView { pub id: String, pub name: String, pub scope: &'static str, pub created_at: String, pub expires: String, pub last_used: String, } impl From<&db::git_access_tokens::DbGitAccessToken> for GitTokenView { fn from(t: &db::git_access_tokens::DbGitAccessToken) -> Self { Self { id: t.id.to_string(), name: t.name.clone(), scope: if t.can_push { "Read + push" } else { "Read only" }, created_at: t.created_at.format("%b %d, %Y").to_string(), expires: t.expires_at.map_or_else(|| "Never".to_string(), |e| e.format("%b %d, %Y").to_string()), last_used: t.last_used_at.map_or_else(|| "Never".to_string(), |e| e.format("%b %d, %Y").to_string()), } } }