Skip to main content

max / makenotwork

5.2 KB · 148 lines History Blame Raw
1 //! Git personal-access-token management API (for git over HTTPS).
2 //!
3 //! The plaintext token is returned exactly once — in the `create` response —
4 //! and never again; only its hash is stored. List/revoke never expose it.
5
6 use axum::extract::{Path, State};
7 use axum::http::HeaderMap;
8 use axum::response::{IntoResponse, Response};
9 use axum::Form;
10 use serde::Deserialize;
11
12 use crate::auth::AuthUser;
13 use crate::db::{self, GitAccessTokenId};
14 use crate::error::{AppError, Result};
15 use crate::helpers::{hx_toast, is_htmx_request};
16 use crate::AppState;
17
18 #[derive(Debug, Deserialize)]
19 pub struct CreateTokenRequest {
20 pub name: String,
21 /// HTML checkbox: present (`"on"`) when checked, absent otherwise.
22 #[serde(default)]
23 pub can_push: Option<String>,
24 /// Optional `YYYY-MM-DD` expiry; empty means no expiry.
25 #[serde(default)]
26 pub expires_on: String,
27 }
28
29 /// GET /api/users/me/git-tokens/list: HTMX partial listing the user's tokens.
30 #[tracing::instrument(skip_all, name = "git_tokens::list_html")]
31 pub(super) async fn list_html(
32 State(state): State<AppState>,
33 AuthUser(user): AuthUser,
34 ) -> Result<impl IntoResponse> {
35 let html = render_list(&state, user.id, None).await?;
36 Ok(axum::response::Html(html))
37 }
38
39 /// POST /api/users/me/git-tokens: mint a new token. Returns the plaintext once.
40 #[tracing::instrument(skip_all, name = "git_tokens::create")]
41 pub(super) async fn create(
42 State(state): State<AppState>,
43 headers: HeaderMap,
44 AuthUser(user): AuthUser,
45 Form(req): Form<CreateTokenRequest>,
46 ) -> Result<Response> {
47 user.check_not_suspended()?;
48 user.check_not_sandbox()?;
49
50 let name = req.name.trim();
51 if name.is_empty() || name.len() > 128 {
52 return Err(AppError::validation("Token name must be 1-128 characters".to_string()));
53 }
54 let can_push = req.can_push.is_some();
55
56 // Parse optional expiry (end of the given day, UTC). Reject past dates.
57 let expires_at = if req.expires_on.trim().is_empty() {
58 None
59 } else {
60 let date = chrono::NaiveDate::parse_from_str(req.expires_on.trim(), "%Y-%m-%d")
61 .map_err(|_| AppError::validation("Invalid expiry date".to_string()))?;
62 let dt = date
63 .and_hms_opt(23, 59, 59)
64 .ok_or_else(|| AppError::validation("Invalid expiry date".to_string()))?
65 .and_utc();
66 if dt <= chrono::Utc::now() {
67 return Err(AppError::validation("Expiry date must be in the future".to_string()));
68 }
69 Some(dt)
70 };
71
72 let (plaintext, hash) = crate::crypto::generate_git_token();
73 db::git_access_tokens::create(&state.db, user.id, name, &hash, can_push, expires_at).await?;
74
75 if is_htmx_request(&headers) {
76 let html = render_list(&state, user.id, Some(&plaintext)).await?;
77 return Ok((
78 [("HX-Trigger", hx_toast("Access token created", "success"))],
79 axum::response::Html(html),
80 )
81 .into_response());
82 }
83
84 // Non-HTMX: the plaintext is the whole point, so return it as plain text.
85 Ok(plaintext.into_response())
86 }
87
88 /// DELETE /api/users/me/git-tokens/{id}: revoke a token.
89 #[tracing::instrument(skip_all, name = "git_tokens::revoke")]
90 pub(super) async fn revoke(
91 State(state): State<AppState>,
92 headers: HeaderMap,
93 AuthUser(user): AuthUser,
94 Path(id): Path<GitAccessTokenId>,
95 ) -> Result<Response> {
96 user.check_not_suspended()?;
97 if !db::git_access_tokens::revoke(&state.db, id, user.id).await? {
98 return Err(AppError::NotFound);
99 }
100 if is_htmx_request(&headers) {
101 let html = render_list(&state, user.id, None).await?;
102 return Ok((
103 [("HX-Trigger", hx_toast("Access token revoked", "success"))],
104 axum::response::Html(html),
105 )
106 .into_response());
107 }
108 Ok(axum::http::StatusCode::NO_CONTENT.into_response())
109 }
110
111 /// Render the tokens list partial, optionally with a freshly-minted plaintext
112 /// shown once at the top.
113 async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
114 use askama::Template;
115 let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?;
116 let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect();
117 Ok(crate::templates::GitTokensListTemplate {
118 tokens,
119 new_token: new_token.map(str::to_string),
120 }
121 .render()
122 .unwrap_or_default())
123 }
124
125 /// View type for token display (never carries the hash or plaintext).
126 #[derive(Clone)]
127 pub struct GitTokenView {
128 pub id: String,
129 pub name: String,
130 pub scope: &'static str,
131 pub created_at: String,
132 pub expires: String,
133 pub last_used: String,
134 }
135
136 impl From<&db::git_access_tokens::DbGitAccessToken> for GitTokenView {
137 fn from(t: &db::git_access_tokens::DbGitAccessToken) -> Self {
138 Self {
139 id: t.id.to_string(),
140 name: t.name.clone(),
141 scope: if t.can_push { "Read + push" } else { "Read only" },
142 created_at: t.created_at.format("%b %d, %Y").to_string(),
143 expires: t.expires_at.map_or_else(|| "Never".to_string(), |e| e.format("%b %d, %Y").to_string()),
144 last_used: t.last_used_at.map_or_else(|| "Never".to_string(), |e| e.format("%b %d, %Y").to_string()),
145 }
146 }
147 }
148