Skip to main content

max / makenotwork

Add git personal-access-tokens for HTTPS clone and push Completes the git-over-HTTPS story: CLI `git clone/push https://...` of a private repo now works via a personal access token, alongside the existing SSH keys and the just-landed read-collaborator authz. Backend: - git_access_tokens table (migration 144): random mnw_-prefixed token shown once; only its SHA-256 hash is stored. Optional expiry; per-token can_push. - crypto::generate_git_token / git_token_hash. - A git HTTP principal resolver: session cookie OR Authorization: Basic <user>:<token> (git sends the token as the password). Wired into the upload-pack (clone) routes so token-authed CLI clones resolve the user for the owner/collaborator read check. - New git-receive-pack (push) route + service=git-receive-pack on info/refs, gated by authorize_push: a principal that's owner-or-push-collaborator AND, if token-authed, a token carrying push scope. The pushed pack is streamed into git's stdin (not buffered); same permit + kill-deadline as upload-pack; 2 GB body cap. UI/API: - Create/list/revoke endpoints + a token section in the git-access dashboard tab (name, optional expiry, push checkbox); the plaintext is shown once. Tests: token clone of a private repo (cookie-free, Basic auth), bad/revoked token → 404, read-only token → 403 on push advert, push token → receive-pack advert, plus create/revoke. +clear_cookies test helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 00:16 UTC
Commit: 6bd9e88c3b84ca0ea86ddcd08877998a25aa3281
Parent: 3474d6a
15 files changed, +656 insertions, -15 deletions
@@ -0,0 +1,16 @@
1 + -- Personal access tokens for git over HTTPS (Basic auth). The plaintext token
2 + -- is shown once at creation and never persisted — only its SHA-256 hex hash is
3 + -- stored. `can_push` gates write (receive-pack) access; `expires_at` NULL means
4 + -- no expiry. Lookups are by token_hash (UNIQUE → indexed); listing is by user.
5 + CREATE TABLE IF NOT EXISTS git_access_tokens (
6 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8 + name TEXT NOT NULL,
9 + token_hash TEXT NOT NULL UNIQUE,
10 + can_push BOOLEAN NOT NULL DEFAULT FALSE,
11 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12 + last_used_at TIMESTAMPTZ,
13 + expires_at TIMESTAMPTZ
14 + );
15 +
16 + CREATE INDEX IF NOT EXISTS idx_git_access_tokens_user ON git_access_tokens(user_id);
@@ -256,6 +256,10 @@ pub const GIT_SMART_HTTP_MAX_CONCURRENT: usize = 8;
256 256 // past this deadline keeps slow/stuck clones from starving the budget. Generous
257 257 // enough for a large repo over a slow link.
258 258 pub const GIT_SMART_HTTP_TIMEOUT_SECS: u64 = 300;
259 + // Max size of a single git push (receive-pack) request body over HTTP. The pack
260 + // is streamed into git's stdin (not buffered), but this caps a single push so a
261 + // runaway upload can't fill the repo disk unbounded.
262 + pub const GIT_RECEIVE_PACK_MAX_BYTES: usize = 2 * 1024 * 1024 * 1024;
259 263
260 264 // -- Webhook security --
261 265 pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
@@ -136,6 +136,27 @@ pub fn generate_key_code() -> crate::db::KeyCode {
136 136 crate::db::KeyCode::from_trusted(words.join("-"))
137 137 }
138 138
139 + /// Generate a git personal-access token. Returns `(plaintext, hash)`: the
140 + /// `mnw_`-prefixed plaintext is shown to the user exactly once and never
141 + /// stored; only the SHA-256 hex `hash` is persisted. The body is 32 CSPRNG
142 + /// bytes (~256 bits) rendered as hex so it's safe in a Basic-auth password / URL.
143 + pub fn generate_git_token() -> (String, String) {
144 + let mut bytes = [0u8; 32];
145 + rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes);
146 + let body: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
147 + let plaintext = format!("mnw_{body}");
148 + let hash = git_token_hash(&plaintext);
149 + (plaintext, hash)
150 + }
151 +
152 + /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
153 + /// token and when verifying one on a request, so the stored hash is never the
154 + /// plaintext and a DB read can't recover a usable credential.
155 + pub fn git_token_hash(token: &str) -> String {
156 + use sha2::{Digest, Sha256};
157 + Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
158 + }
159 +
139 160 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
140 161 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
141 162 use hmac::{Hmac, Mac};
@@ -0,0 +1,102 @@
1 + //! Personal access tokens for git over HTTPS (Basic auth).
2 + //!
3 + //! The plaintext token is shown to the user once at creation and never stored;
4 + //! only its SHA-256 hex hash lives in the DB, so a database read can't recover
5 + //! a usable credential. Lookups hash the presented token and match the column.
6 +
7 + use chrono::{DateTime, Utc};
8 + use sqlx::PgPool;
9 +
10 + use crate::db::{GitAccessTokenId, UserId};
11 + use crate::error::Result;
12 +
13 + /// A git access token row, minus the hash (never returned to handlers/UI).
14 + #[derive(Debug, sqlx::FromRow)]
15 + pub struct DbGitAccessToken {
16 + pub id: GitAccessTokenId,
17 + pub user_id: UserId,
18 + pub name: String,
19 + pub can_push: bool,
20 + pub created_at: DateTime<Utc>,
21 + pub last_used_at: Option<DateTime<Utc>>,
22 + pub expires_at: Option<DateTime<Utc>>,
23 + }
24 +
25 + /// The user + push scope resolved from a presented token (active tokens only).
26 + pub struct ResolvedToken {
27 + pub user_id: UserId,
28 + pub can_push: bool,
29 + }
30 +
31 + /// Create a token. `token_hash` is the SHA-256 hex of the plaintext (the caller
32 + /// generates the plaintext via `crypto::generate_git_token` and shows it once).
33 + #[tracing::instrument(skip_all)]
34 + pub async fn create(
35 + pool: &PgPool,
36 + user_id: UserId,
37 + name: &str,
38 + token_hash: &str,
39 + can_push: bool,
40 + expires_at: Option<DateTime<Utc>>,
41 + ) -> Result<GitAccessTokenId> {
42 + let id = sqlx::query_scalar::<_, GitAccessTokenId>(
43 + r#"INSERT INTO git_access_tokens (user_id, name, token_hash, can_push, expires_at)
44 + VALUES ($1, $2, $3, $4, $5)
45 + RETURNING id"#,
46 + )
47 + .bind(user_id)
48 + .bind(name)
49 + .bind(token_hash)
50 + .bind(can_push)
51 + .bind(expires_at)
52 + .fetch_one(pool)
53 + .await?;
54 +
55 + Ok(id)
56 + }
57 +
58 + /// List a user's tokens (no hashes), newest first.
59 + #[tracing::instrument(skip_all)]
60 + pub async fn list_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitAccessToken>> {
61 + let rows = sqlx::query_as::<_, DbGitAccessToken>(
62 + r#"SELECT id, user_id, name, can_push, created_at, last_used_at, expires_at
63 + FROM git_access_tokens WHERE user_id = $1 ORDER BY created_at DESC"#,
64 + )
65 + .bind(user_id)
66 + .fetch_all(pool)
67 + .await?;
68 +
69 + Ok(rows)
70 + }
71 +
72 + /// Revoke (delete) a token the user owns. Returns true if a row was removed.
73 + #[tracing::instrument(skip_all)]
74 + pub async fn revoke(pool: &PgPool, id: GitAccessTokenId, user_id: UserId) -> Result<bool> {
75 + let result = sqlx::query("DELETE FROM git_access_tokens WHERE id = $1 AND user_id = $2")
76 + .bind(id)
77 + .bind(user_id)
78 + .execute(pool)
79 + .await?;
80 +
81 + Ok(result.rows_affected() > 0)
82 + }
83 +
84 + /// Resolve a presented token's hash to its (user, push-scope), if the token
85 + /// exists and has not expired. Stamps `last_used_at` on a hit. Returns `None`
86 + /// for unknown or expired tokens — callers must treat that as unauthenticated.
87 + #[tracing::instrument(skip_all)]
88 + pub async fn resolve_active(pool: &PgPool, token_hash: &str) -> Result<Option<ResolvedToken>> {
89 + // UPDATE...RETURNING does the expiry filter and the last_used_at stamp in
90 + // one round-trip; an expired token matches no row, so it resolves to None.
91 + let row: Option<(UserId, bool)> = sqlx::query_as(
92 + r#"UPDATE git_access_tokens
93 + SET last_used_at = NOW()
94 + WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())
95 + RETURNING user_id, can_push"#,
96 + )
97 + .bind(token_hash)
98 + .fetch_optional(pool)
99 + .await?;
100 +
101 + Ok(row.map(|(user_id, can_push)| ResolvedToken { user_id, can_push }))
102 + }
@@ -163,6 +163,7 @@ define_pg_uuid_id!(
163 163 InviteCodeId,
164 164 GitRepoId,
165 165 SshKeyId,
166 + GitAccessTokenId,
166 167 IssueId,
167 168 IssueCommentId,
168 169 IssueLabelId,
@@ -47,6 +47,7 @@ pub(crate) mod email_suppressions;
47 47 pub mod git_repos;
48 48 pub mod repo_collaborators;
49 49 pub mod ssh_keys;
50 + pub mod git_access_tokens;
50 51 pub mod issues;
51 52 pub(crate) mod reports;
52 53 pub(crate) mod fan_plus;
@@ -0,0 +1,147 @@
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 + }
@@ -36,6 +36,7 @@ mod projects;
36 36 mod promo_codes;
37 37 mod reports;
38 38 pub(crate) mod ssh_keys;
39 + pub(crate) mod git_tokens;
39 40 mod subscriptions;
40 41 mod tags;
41 42 pub(crate) mod totp;
@@ -323,6 +324,9 @@ pub fn api_routes() -> CsrfRouter<AppState> {
323 324 .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
324 325 .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key))
325 326 .route("/api/users/me/ssh-keys/{id}", delete_csrf(ssh_keys::delete_key))
327 + // Git access tokens (HTTPS)
328 + .route("/api/users/me/git-tokens", post_csrf(git_tokens::create))
329 + .route("/api/users/me/git-tokens/{id}", delete_csrf(git_tokens::revoke))
326 330 // Reports
327 331 .route("/api/reports", post_csrf(reports::submit_report))
328 332 // Collections
@@ -403,6 +407,7 @@ pub fn api_routes() -> CsrfRouter<AppState> {
403 407 .route_get("/api/users/me/passkeys", get(passkeys::list))
404 408 // SSH key list (HTMX partial for dashboard)
405 409 .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html))
410 + .route_get("/api/users/me/git-tokens/list", get(git_tokens::list_html))
406 411 // Content insertion list (HTMX partials for dashboard)
407 412 .route_get("/api/users/me/insertions", get(content_insertions::list_insertions))
408 413 .route_get("/api/items/{id}/insertions", get(content_insertions::list_placements))
@@ -51,6 +51,11 @@ pub fn git_routes() -> Router<AppState> {
51 51 post(raw::smart_http_upload_pack)
52 52 .layer(DefaultBodyLimit::max(constants::GIT_UPLOAD_PACK_MAX_BYTES)),
53 53 )
54 + .route(
55 + "/git/{owner}/{repo}/git-receive-pack",
56 + post(raw::smart_http_receive_pack)
57 + .layer(DefaultBodyLimit::max(constants::GIT_RECEIVE_PACK_MAX_BYTES)),
58 + )
54 59 .route_layer(GovernorLayer { config: browse_rate_limit })
55 60 }
56 61
@@ -214,6 +219,44 @@ pub(crate) async fn resolve_repo(
214 219 Ok(ResolvedRepo { db_repo, repo_path, db_user })
215 220 }
216 221
222 + /// Who is making a git smart-HTTP request, resolved from either a session
223 + /// cookie (browser) or HTTP Basic auth carrying a personal access token (CLI).
224 + pub(crate) struct GitHttpPrincipal {
225 + pub user_id: UserId,
226 + /// `Some(can_push)` when authenticated by a personal access token (its push
227 + /// scope); `None` when authenticated by a session cookie — a session's push
228 + /// capability is decided by the owner/collaborator check at the route, not a
229 + /// token scope.
230 + pub token_push: Option<bool>,
231 + }
232 +
233 + /// Resolve the git HTTP principal. A session cookie wins when present; otherwise
234 + /// an `Authorization: Basic <user>:<token>` header is verified against the
235 + /// hashed personal-access-tokens (git sends the token as the Basic-auth
236 + /// password; the username is ignored). Returns `None` for anonymous, unknown,
237 + /// or expired credentials — callers treat that as unauthenticated, and a
238 + /// private repo then 404s exactly as it does for an anonymous browser request.
239 + pub(crate) async fn resolve_git_http_principal(
240 + state: &AppState,
241 + headers: &axum::http::HeaderMap,
242 + session_user_id: Option<UserId>,
243 + ) -> Option<GitHttpPrincipal> {
244 + if let Some(user_id) = session_user_id {
245 + return Some(GitHttpPrincipal { user_id, token_push: None });
246 + }
247 + let header = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
248 + let b64 = header.strip_prefix("Basic ")?;
249 + use base64::Engine;
250 + let decoded = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
251 + let creds = String::from_utf8(decoded).ok()?;
252 + // "username:token" — git puts the token in the password field; the username
253 + // is irrelevant (the token alone identifies the user).
254 + let token = creds.split_once(':').map_or(creds.as_str(), |(_, t)| t);
255 + let hash = crate::crypto::git_token_hash(token);
256 + let resolved = db::git_access_tokens::resolve_active(&state.db, &hash).await.ok()??;
257 + Some(GitHttpPrincipal { user_id: resolved.user_id, token_push: Some(resolved.can_push) })
258 + }
259 +
217 260 // ============================================================================
218 261 // Linked project + releases
219 262 // ============================================================================
@@ -98,43 +98,54 @@ pub(super) async fn smart_http_info_refs(
98 98 MaybeUserVerified(maybe_user): MaybeUserVerified,
99 99 Path((owner, repo_name)): Path<(String, String)>,
100 100 Query(query): Query<InfoRefsQuery>,
101 + headers: axum::http::HeaderMap,
101 102 ) -> Result<Response> {
102 103 let service = query.service.as_deref().unwrap_or("");
103 - if service != "git-upload-pack" {
104 + if service != "git-upload-pack" && service != "git-receive-pack" {
104 105 return Err(AppError::Forbidden);
105 106 }
106 107
108 + // Resolve session OR personal-access-token (Basic auth) so `git clone`/push
109 + // of a private repo works from the CLI, not just a logged-in browser.
110 + let principal = super::resolve_git_http_principal(
111 + &state, &headers, maybe_user.as_ref().map(|u| u.id),
112 + ).await;
107 113 let repo_name = resolve_repo_name(&repo_name);
108 - resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
114 + let resolved = resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
109 115
110 - let root = repos_root(&state)?;
111 - let repo_path = git::repo_disk_path(&root, &owner, repo_name)?;
116 + // The receive-pack (push) advertisement requires write authorization; the
117 + // upload-pack (read) advertisement only needs the read access resolve_repo
118 + // already enforced.
119 + if service == "git-receive-pack" {
120 + authorize_push(&state, &resolved, principal.as_ref()).await?;
121 + }
112 122
113 123 // The ref advertisement is NOT gated by `git_smart_http_semaphore`: it's a
114 - // short, bounded `git upload-pack --advertise-refs` that buffers only the
124 + // short, bounded `git <service> --advertise-refs` that buffers only the
115 125 // (KB-sized) ref list and exits immediately, and the route is already
116 126 // rate-limited by the GovernorLayer. Sharing the upload-pack budget made a
117 127 // burst of slow clones (holding their permits during the pack transfer)
118 128 // stall every *new* clone at the handshake — the budget is for the
119 129 // expensive streaming half only (Run #21 Performance).
130 + let git_subcommand = &service["git-".len()..]; // "upload-pack" | "receive-pack"
120 131 let output = tokio::process::Command::new("git")
121 - .arg("upload-pack")
132 + .arg(git_subcommand)
122 133 .arg("--stateless-rpc")
123 134 .arg("--advertise-refs")
124 - .arg(&repo_path)
135 + .arg(&resolved.repo_path)
125 136 .output()
126 137 .await
127 - .context("run git upload-pack")?;
138 + .context("run git smart-http advertise-refs")?;
128 139
129 140 if !output.status.success() {
130 141 return Err(AppError::Internal(anyhow::anyhow!(
131 - "git upload-pack exited with {}",
142 + "git {git_subcommand} exited with {}",
132 143 output.status
133 144 )));
134 145 }
135 146
136 147 let mut body = Vec::new();
137 - let header_line = "# service=git-upload-pack\n";
148 + let header_line = format!("# service={service}\n");
138 149 let pkt_len = header_line.len() + 4;
139 150 body.extend_from_slice(format!("{pkt_len:04x}").as_bytes());
140 151 body.extend_from_slice(header_line.as_bytes());
@@ -142,25 +153,52 @@ pub(super) async fn smart_http_info_refs(
142 153 body.extend_from_slice(&output.stdout);
143 154
144 155 Response::builder()
145 - .header(
146 - header::CONTENT_TYPE,
147 - "application/x-git-upload-pack-advertisement",
148 - )
156 + .header(header::CONTENT_TYPE, format!("application/x-{service}-advertisement"))
149 157 .header(header::CACHE_CONTROL, "no-cache")
150 158 .body(Body::from(body))
151 159 .context("build git http response")
152 160 }
153 161
162 + /// Authorize a push (receive-pack) for the resolved principal. Requires a
163 + /// principal (no anonymous push); a token-authed principal must carry push
164 + /// scope (`token_push == Some(true)`); and the user must be the repo owner or a
165 + /// push-collaborator — the same write model SSH enforces in `git_ssh.rs`.
166 + async fn authorize_push(
167 + state: &AppState,
168 + resolved: &super::ResolvedRepo,
169 + principal: Option<&super::GitHttpPrincipal>,
170 + ) -> Result<()> {
171 + let principal = principal.ok_or(AppError::Unauthorized)?;
172 + if principal.token_push == Some(false) {
173 + // Authenticated by a read-only token.
174 + return Err(AppError::Forbidden);
175 + }
176 + let is_owner = resolved.db_user.id == principal.user_id;
177 + if !is_owner {
178 + let can_push = crate::db::repo_collaborators::can_user_push(
179 + &state.db, resolved.db_repo.id, principal.user_id,
180 + ).await?;
181 + if !can_push {
182 + return Err(AppError::Forbidden);
183 + }
184 + }
185 + Ok(())
186 + }
187 +
154 188 /// `POST /git/{owner}/{repo}/git-upload-pack`
155 189 #[tracing::instrument(skip_all, name = "git::smart_http_upload_pack")]
156 190 pub(super) async fn smart_http_upload_pack(
157 191 State(state): State<AppState>,
158 192 MaybeUserVerified(maybe_user): MaybeUserVerified,
159 193 Path((owner, repo_name)): Path<(String, String)>,
194 + headers: axum::http::HeaderMap,
160 195 body: axum::body::Bytes,
161 196 ) -> Result<Response> {
197 + let principal = super::resolve_git_http_principal(
198 + &state, &headers, maybe_user.as_ref().map(|u| u.id),
199 + ).await;
162 200 let repo_name = resolve_repo_name(&repo_name);
163 - resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
201 + resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
164 202
165 203 let root = repos_root(&state)?;
166 204 let repo_path = git::repo_disk_path(&root, &owner, repo_name)?;
@@ -232,3 +270,87 @@ pub(super) async fn smart_http_upload_pack(
232 270 .body(Body::from_stream(ReaderStream::new(stdout)))
233 271 .context("build git http response")
234 272 }
273 +
274 + /// `POST /git/{owner}/{repo}/git-receive-pack` — the push (write) half of git
275 + /// smart HTTP. Requires write authorization (see `authorize_push`). The request
276 + /// body carries the pushed packfile, which can be large, so it's streamed into
277 + /// `git receive-pack`'s stdin rather than buffered; the result report is
278 + /// streamed back out. Mirrors the SSH receive-pack path's authz model.
279 + #[tracing::instrument(skip_all, name = "git::smart_http_receive_pack")]
280 + pub(super) async fn smart_http_receive_pack(
281 + State(state): State<AppState>,
282 + MaybeUserVerified(maybe_user): MaybeUserVerified,
283 + Path((owner, repo_name)): Path<(String, String)>,
284 + headers: axum::http::HeaderMap,
285 + body: axum::body::Body,
286 + ) -> Result<Response> {
287 + let principal = super::resolve_git_http_principal(
288 + &state, &headers, maybe_user.as_ref().map(|u| u.id),
289 + ).await;
290 + let repo_name = resolve_repo_name(&repo_name);
291 + let resolved = resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
292 + authorize_push(&state, &resolved, principal.as_ref()).await?;
293 +
294 + let permit = state
295 + .git_smart_http_semaphore
296 + .clone()
297 + .acquire_owned()
298 + .await
299 + .context("acquire git smart-http permit")?;
300 +
301 + let mut child = tokio::process::Command::new("git")
302 + .arg("receive-pack")
303 + .arg("--stateless-rpc")
304 + .arg(&resolved.repo_path)
305 + .stdin(std::process::Stdio::piped())
306 + .stdout(std::process::Stdio::piped())
307 + .stderr(std::process::Stdio::null())
308 + .spawn()
309 + .context("spawn git receive-pack")?;
310 +
311 + // Stream the (possibly large) pushed packfile into stdin without buffering
312 + // the whole request in memory, then close stdin (EOF). `receive-pack` reads
313 + // it all before writing its report, so streaming-then-EOF can't deadlock.
314 + let mut stdin = child
315 + .stdin
316 + .take()
317 + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("git receive-pack stdin missing")))?;
318 + tokio::spawn(async move {
319 + use tokio::io::AsyncWriteExt;
320 + use tokio_stream::StreamExt;
321 + let mut stream = body.into_data_stream();
322 + while let Some(chunk) = stream.next().await {
323 + match chunk {
324 + Ok(bytes) => {
325 + if stdin.write_all(&bytes).await.is_err() {
326 + break;
327 + }
328 + }
329 + Err(_) => break,
330 + }
331 + }
332 + let _ = stdin.shutdown().await;
333 + });
334 +
335 + let stdout = child
336 + .stdout
337 + .take()
338 + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("git receive-pack stdout missing")))?;
339 +
340 + // Same reap + kill-deadline + permit-release discipline as upload-pack.
341 + tokio::spawn(async move {
342 + let _permit = permit;
343 + let deadline = std::time::Duration::from_secs(constants::GIT_SMART_HTTP_TIMEOUT_SECS);
344 + if tokio::time::timeout(deadline, child.wait()).await.is_err() {
345 + let _ = child.start_kill();
346 + let _ = child.wait().await;
347 + }
348 + });
349 +
350 + Response::builder()
351 + .status(StatusCode::OK)
352 + .header(header::CONTENT_TYPE, "application/x-git-receive-pack-result")
353 + .header(header::CACHE_CONTROL, "no-cache")
354 + .body(Body::from_stream(ReaderStream::new(stdout)))
355 + .context("build git http response")
356 + }
@@ -634,6 +634,15 @@ pub struct UserSshKeysTabTemplate {
634 634 pub username: String,
635 635 }
636 636
637 + /// Git access-token list partial for HTMX updates. `new_token` carries a
638 + /// freshly-minted plaintext to show once (set only in the create response).
639 + #[derive(Template)]
640 + #[template(path = "partials/git_tokens_list.html")]
641 + pub struct GitTokensListTemplate {
642 + pub tokens: Vec<crate::routes::api::git_tokens::GitTokenView>,
643 + pub new_token: Option<String>,
644 + }
645 +
637 646 /// Sessions list partial, used by session revocation API responses (HTMX swap into `#sessions-list`).
638 647 #[derive(Template)]
639 648 #[template(path = "partials/tabs/user_sessions.html")]
@@ -0,0 +1,40 @@
1 + {% if let Some(token) = new_token %}
2 + <div class="git-token-new">
3 + <p class="git-token-new-label">Copy this token now — it will not be shown again:</p>
4 + <code class="git-token-value">{{ token }}</code>
5 + <p class="hint">Use it as the password (or in the URL) for
6 + <code>git clone https://{{ token }}@makenot.work/owner/repo.git</code></p>
7 + </div>
8 + {% endif %}
9 + {% if tokens.is_empty() %}
10 + <p class="ssh-keys-empty">No access tokens.</p>
11 + {% else %}
12 + <table class="ssh-keys-table">
13 + <thead>
14 + <tr>
15 + <th>Name</th>
16 + <th>Scope</th>
17 + <th>Expires</th>
18 + <th>Last used</th>
19 + <th class="ssh-keys-th-actions"></th>
20 + </tr>
21 + </thead>
22 + <tbody>
23 + {% for t in tokens %}
24 + <tr>
25 + <td>{{ t.name }}</td>
26 + <td>{{ t.scope }}</td>
27 + <td>{{ t.expires }}</td>
28 + <td class="ssh-keys-added">{{ t.last_used }}</td>
29 + <td class="ssh-keys-actions">
30 + <button class="btn-danger small"
31 + hx-delete="/api/users/me/git-tokens/{{ t.id }}"
32 + hx-target="#git-tokens-list"
33 + hx-swap="innerHTML"
34 + hx-confirm="Revoke this token?">Revoke</button>
35 + </td>
36 + </tr>
37 + {% endfor %}
38 + </tbody>
39 + </table>
40 + {% endif %}
@@ -31,3 +31,39 @@
31 31 </div>
32 32 </form>
33 33 </div>
34 +
35 + <div class="form-section">
36 + <h2 class="subsection-title">Access Tokens (HTTPS)</h2>
37 + <p class="credentials-lead">
38 + Personal access tokens for git over HTTPS — use a token as the password.
39 + Clone URL: <code>https://&lt;token&gt;@makenot.work/{{ username }}/{repo}.git</code>
40 + </p>
41 +
42 + <div id="git-tokens-list"
43 + hx-get="/api/users/me/git-tokens/list"
44 + hx-trigger="load"
45 + hx-swap="innerHTML">
46 + <p class="credentials-loading">Loading...</p>
47 + </div>
48 +
49 + <form class="credentials-add-form"
50 + hx-post="/api/users/me/git-tokens"
51 + hx-target="#git-tokens-list"
52 + hx-swap="innerHTML"
53 + hx-on::after-request="if(event.detail.successful) this.reset()">
54 + <div class="credentials-add-row">
55 + <div class="form-group">
56 + <label for="git-token-name">Name</label>
57 + <input type="text" id="git-token-name" name="name" placeholder="e.g., laptop" maxlength="128" required>
58 + </div>
59 + <div class="form-group">
60 + <label for="git-token-expires">Expires (optional)</label>
61 + <input type="date" id="git-token-expires" name="expires_on">
62 + </div>
63 + </div>
64 + <div class="form-group">
65 + <label class="checkbox-label"><input type="checkbox" name="can_push"> Allow push (write access)</label>
66 + </div>
67 + <button class="btn-secondary" type="submit">Create Token</button>
68 + </form>
69 + </div>
@@ -55,6 +55,14 @@ impl TestClient {
55 55 self.bearer_token = None;
56 56 }
57 57
58 + /// Drop all stored cookies — simulates a fresh client with no session, e.g.
59 + /// a CLI `git` request that authenticates via a token rather than a browser
60 + /// cookie.
61 + #[allow(dead_code)]
62 + pub fn clear_cookies(&mut self) {
63 + self.cookies.clear();
64 + }
65 +
58 66 /// Access the current CSRF token (if any).
59 67 #[allow(dead_code)]
60 68 pub fn csrf_token(&self) -> Option<&str> {
@@ -679,6 +679,92 @@ async fn git_tree_no_emoji() {
679 679 assert!(!resp.text.contains("\u{1F4C4}"), "Subdirectory should not have file emoji");
680 680 }
681 681
682 + // ── Personal access tokens (git over HTTPS) ──
683 +
684 + fn basic_auth(token: &str) -> String {
685 + use base64::Engine;
686 + // git puts the token in the password field; username is ignored.
687 + let creds = base64::engine::general_purpose::STANDARD.encode(format!("x:{token}"));
688 + format!("Basic {creds}")
689 + }
690 +
691 + #[tokio::test]
692 + async fn git_token_clones_private_repo_and_revokes() {
693 + let tmp = tempfile::TempDir::new().unwrap();
694 + make_test_repo(tmp.path());
695 + let mut h = setup_git_harness(&tmp).await; // signs up + logs in testowner
696 +
697 + h.client.get("/git/testowner/testrepo").await; // auto-register
698 + sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
699 + .execute(&h.db).await.unwrap();
700 +
701 + // Owner mints a read-only token; the create response body is the plaintext.
702 + h.login("testowner", "password123").await;
703 + h.client.fetch_csrf_token().await;
704 + let resp = h.client.post_form("/api/users/me/git-tokens", "name=laptop").await;
705 + assert!(resp.status.is_success(), "create token: {} {}", resp.status, resp.text);
706 + let token = resp.text.trim().to_string();
707 + assert!(token.starts_with("mnw_"), "expected mnw_-prefixed token, got: {token}");
708 +
709 + // Simulate a CLI clone: no session cookie, token via Basic auth.
710 + h.client.clear_cookies();
711 + let info_refs = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack";
712 +
713 + // No credentials → private repo is invisible.
714 + let resp = h.client.get(info_refs).await;
715 + assert_eq!(resp.status, 404, "anonymous must not reach a private repo");
716 +
717 + // Valid token → clone advertisement succeeds.
718 + let resp = h.client.request_with_headers("GET", info_refs, None, &[("Authorization", &basic_auth(&token))]).await;
719 + assert!(resp.status.is_success(), "token clone failed: {} {}", resp.status, resp.text);
720 + assert!(resp.text.contains("refs/heads/main"), "advertisement missing refs: {}", resp.text);
721 +
722 + // Garbage token → still 404.
723 + let resp = h.client.request_with_headers("GET", info_refs, None, &[("Authorization", &basic_auth("mnw_bogus"))]).await;
724 + assert_eq!(resp.status, 404, "bad token must not authorize");
725 +
726 + // Read-only token cannot push: receive-pack advertisement is forbidden.
727 + let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
728 + let resp = h.client.request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]).await;
729 + assert_eq!(resp.status, 403, "read-only token must not get a push advertisement: {}", resp.status);
730 +
731 + // Revoke the token (re-auth as owner first).
732 + let token_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_access_tokens LIMIT 1")
733 + .fetch_one(&h.db).await.unwrap();
734 + h.login("testowner", "password123").await;
735 + h.client.fetch_csrf_token().await;
736 + let resp = h.client.delete(&format!("/api/users/me/git-tokens/{token_id}")).await;
737 + assert!(resp.status.is_success() || resp.status == 204, "revoke: {} {}", resp.status, resp.text);
738 +
739 + // Revoked token no longer authorizes.
740 + h.client.clear_cookies();
741 + let resp = h.client.request_with_headers("GET", info_refs, None, &[("Authorization", &basic_auth(&token))]).await;
742 + assert_eq!(resp.status, 404, "revoked token must stop working");
743 + }
744 +
745 + #[tokio::test]
746 + async fn git_push_token_gets_receive_pack_advertisement() {
747 + let tmp = tempfile::TempDir::new().unwrap();
748 + make_test_repo(tmp.path());
749 + let mut h = setup_git_harness(&tmp).await;
750 +
751 + h.client.get("/git/testowner/testrepo").await;
752 + sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
753 + .execute(&h.db).await.unwrap();
754 +
755 + h.login("testowner", "password123").await;
756 + h.client.fetch_csrf_token().await;
757 + let resp = h.client.post_form("/api/users/me/git-tokens", "name=ci&can_push=on").await;
758 + assert!(resp.status.is_success(), "create push token: {} {}", resp.status, resp.text);
759 + let token = resp.text.trim().to_string();
760 +
761 + h.client.clear_cookies();
762 + let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
763 + let resp = h.client.request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]).await;
764 + assert!(resp.status.is_success(), "push token should get receive-pack advertisement: {} {}", resp.status, resp.text);
765 + assert!(resp.text.contains("# service=git-receive-pack"), "missing receive-pack banner: {}", resp.text);
766 + }
767 +
682 768 // ── Smart HTTP (git clone) ──
683 769
684 770 /// Tip commit sha of refs/heads/main from the on-disk bare repo.