max / makenotwork
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://<token>@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. |