Skip to main content

max / makenotwork

5.3 KB · 159 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::Form;
7 use axum::extract::{Path, State};
8 use axum::http::HeaderMap;
9 use axum::response::{IntoResponse, Response};
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 sqlx::PgPool;
17
18 #[derive(Debug, Deserialize)]
19 pub(crate) 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(db): State<PgPool>,
33 AuthUser(user): AuthUser,
34 ) -> Result<impl IntoResponse> {
35 let html = render_list(&db, 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(db): State<PgPool>,
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(
53 "Token name must be 1-128 characters".to_string(),
54 ));
55 }
56 let can_push = req.can_push.is_some();
57
58 // Parse optional expiry (end of the given day, UTC). Reject past dates.
59 let expires_at = if req.expires_on.trim().is_empty() {
60 None
61 } else {
62 let date = chrono::NaiveDate::parse_from_str(req.expires_on.trim(), "%Y-%m-%d")
63 .map_err(|_| AppError::validation("Invalid expiry date".to_string()))?;
64 let dt = date
65 .and_hms_opt(23, 59, 59)
66 .ok_or_else(|| AppError::validation("Invalid expiry date".to_string()))?
67 .and_utc();
68 if dt <= chrono::Utc::now() {
69 return Err(AppError::validation(
70 "Expiry date must be in the future".to_string(),
71 ));
72 }
73 Some(dt)
74 };
75
76 let (plaintext, hash) = crate::crypto::generate_git_token();
77 db::git_access_tokens::create(&db, user.id, name, &hash, can_push, expires_at).await?;
78
79 if is_htmx_request(&headers) {
80 let html = render_list(&db, user.id, Some(&plaintext)).await?;
81 return Ok((
82 [("HX-Trigger", hx_toast("Access token created", "success"))],
83 axum::response::Html(html),
84 )
85 .into_response());
86 }
87
88 // Non-HTMX: the plaintext is the whole point, so return it as plain text.
89 Ok(plaintext.into_response())
90 }
91
92 /// DELETE /api/users/me/git-tokens/{id}: revoke a token.
93 #[tracing::instrument(skip_all, name = "git_tokens::revoke")]
94 pub(super) async fn revoke(
95 State(db): State<PgPool>,
96 headers: HeaderMap,
97 AuthUser(user): AuthUser,
98 Path(id): Path<GitAccessTokenId>,
99 ) -> Result<Response> {
100 user.check_not_suspended()?;
101 if !db::git_access_tokens::revoke(&db, id, user.id).await? {
102 return Err(AppError::NotFound);
103 }
104 if is_htmx_request(&headers) {
105 let html = render_list(&db, user.id, None).await?;
106 return Ok((
107 [("HX-Trigger", hx_toast("Access token revoked", "success"))],
108 axum::response::Html(html),
109 )
110 .into_response());
111 }
112 Ok(axum::http::StatusCode::NO_CONTENT.into_response())
113 }
114
115 /// Render the tokens list partial, optionally with a freshly-minted plaintext
116 /// shown once at the top.
117 async fn render_list(db: &PgPool, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
118 let tokens = db::git_access_tokens::list_by_user(db, user_id).await?;
119 let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect();
120 crate::helpers::render_fragment(&crate::templates::GitTokensListTemplate {
121 tokens,
122 new_token: new_token.map(str::to_string),
123 })
124 }
125
126 /// View type for token display (never carries the hash or plaintext).
127 #[derive(Clone)]
128 pub struct GitTokenView {
129 pub id: String,
130 pub name: String,
131 pub scope: &'static str,
132 pub created_at: String,
133 pub expires: String,
134 pub last_used: String,
135 }
136
137 impl From<&db::git_access_tokens::DbGitAccessToken> for GitTokenView {
138 fn from(t: &db::git_access_tokens::DbGitAccessToken) -> Self {
139 Self {
140 id: t.id.to_string(),
141 name: t.name.clone(),
142 scope: if t.can_push {
143 "Read + push"
144 } else {
145 "Read only"
146 },
147 created_at: t.created_at.format("%b %d, %Y").to_string(),
148 expires: t.expires_at.map_or_else(
149 || "Never".to_string(),
150 |e| e.format("%b %d, %Y").to_string(),
151 ),
152 last_used: t.last_used_at.map_or_else(
153 || "Never".to_string(),
154 |e| e.format("%b %d, %Y").to_string(),
155 ),
156 }
157 }
158 }
159