Skip to main content

max / makenotwork

Add internal git authorize endpoint for mnw-cli proxy POST /api/internal/git/authorize — authenticates git operations, auto-creates bare repos on first push, returns on-disk repo path. Mirrors mnw-admin git-auth logic for the SSH CLI takeover. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 22:03 UTC
Commit: 93b80b710ec032e0db87a41f9d1d07da9aacf02b
Parent: 1195fbe
2 files changed, +123 insertions, -1 deletion
@@ -1467,3 +1467,123 @@ pub(super) async fn list_ssh_keys(
1467 1467
1468 1468 Ok(Json(data))
1469 1469 }
1470 +
1471 + // ── Git authorization ──
1472 +
1473 + #[derive(Deserialize)]
1474 + pub(super) struct GitAuthorizeRequest {
1475 + user_id: UserId,
1476 + /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
1477 + operation: String,
1478 + owner: String,
1479 + repo_name: String,
1480 + }
1481 +
1482 + #[derive(Serialize)]
1483 + struct GitAuthorizeResponse {
1484 + repo_path: String,
1485 + }
1486 +
1487 + /// POST /api/internal/git/authorize
1488 + ///
1489 + /// Authorize a git operation and return the on-disk repo path.
1490 + /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
1491 + #[tracing::instrument(skip_all, name = "internal::git_authorize")]
1492 + pub(super) async fn git_authorize(
1493 + State(state): State<AppState>,
1494 + _auth: ServiceAuth,
1495 + Json(req): Json<GitAuthorizeRequest>,
1496 + ) -> Result<impl IntoResponse> {
1497 + let git_root = state
1498 + .config
1499 + .git_repos_path
1500 + .as_deref()
1501 + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("GIT_REPOS_PATH not configured")))?;
1502 +
1503 + // Look up the namespace owner
1504 + let owner_user = db::users::get_user_by_username(
1505 + &state.db,
1506 + &Username::from_trusted(req.owner.clone()),
1507 + )
1508 + .await?
1509 + .ok_or(AppError::NotFound)?;
1510 +
1511 + let repo = match db::git_repos::get_repo_by_user_and_name(
1512 + &state.db,
1513 + owner_user.id,
1514 + &req.repo_name,
1515 + )
1516 + .await?
1517 + {
1518 + Some(repo) => repo,
1519 + None => {
1520 + // Auto-create on push if the authenticated user owns the namespace
1521 + if req.operation != "git-receive-pack" || req.user_id != owner_user.id {
1522 + return Err(AppError::NotFound);
1523 + }
1524 +
1525 + let owner_dir = std::path::Path::new(git_root).join(&req.owner);
1526 + let repo_dir = owner_dir.join(format!("{}.git", req.repo_name));
1527 +
1528 + std::fs::create_dir_all(&owner_dir)
1529 + .map_err(|e| AppError::Internal(e.into()))?;
1530 + git2::Repository::init_bare(&repo_dir)
1531 + .map_err(|e| AppError::Internal(e.into()))?;
1532 +
1533 + // Install post-receive hook if build trigger token is configured
1534 + if let Some(ref token) = state.config.build_trigger_token {
1535 + let hook_content = crate::build_runner::post_receive_hook(token);
1536 + let hooks_dir = repo_dir.join("hooks");
1537 + std::fs::create_dir_all(&hooks_dir)
1538 + .map_err(|e| AppError::Internal(e.into()))?;
1539 + let hook_path = hooks_dir.join("post-receive");
1540 + std::fs::write(&hook_path, &hook_content)
1541 + .map_err(|e| AppError::Internal(e.into()))?;
1542 + #[cfg(unix)]
1543 + {
1544 + use std::os::unix::fs::PermissionsExt;
1545 + let _ = std::fs::set_permissions(
1546 + &hook_path,
1547 + std::fs::Permissions::from_mode(0o755),
1548 + );
1549 + }
1550 + }
1551 +
1552 + // Fix ownership so the git user can write
1553 + let status = std::process::Command::new("chown")
1554 + .args(["-R", "git:git"])
1555 + .arg(&repo_dir)
1556 + .status()
1557 + .map_err(|e| AppError::Internal(e.into()))?;
1558 + if !status.success() {
1559 + return Err(AppError::Internal(anyhow::anyhow!("chown failed on {}", repo_dir.display())));
1560 + }
1561 +
1562 + tracing::info!(owner = %req.owner, repo = %req.repo_name, "auto-created bare repository");
1563 + db::git_repos::create_repo(&state.db, owner_user.id, &req.repo_name).await?
1564 + }
1565 + };
1566 +
1567 + // Permission check
1568 + match req.operation.as_str() {
1569 + "git-receive-pack" => {
1570 + if req.user_id != owner_user.id {
1571 + return Err(AppError::Forbidden);
1572 + }
1573 + }
1574 + "git-upload-pack" | "git-upload-archive" => {
1575 + if repo.visibility == "private" && req.user_id != owner_user.id {
1576 + return Err(AppError::NotFound);
1577 + }
1578 + }
1579 + _ => return Err(AppError::BadRequest("unsupported git operation".into())),
1580 + }
1581 +
1582 + let repo_path = std::path::Path::new(git_root)
1583 + .join(&req.owner)
1584 + .join(format!("{}.git", req.repo_name));
1585 +
1586 + Ok(Json(GitAuthorizeResponse {
1587 + repo_path: repo_path.to_string_lossy().into_owned(),
1588 + }))
1589 + }
@@ -388,7 +388,9 @@ pub fn api_routes() -> Router<AppState> {
388 388 .route("/api/internal/creator/transactions", get(internal::creator_transactions))
389 389 .route("/api/internal/creator/export/sales", get(internal::export_sales))
390 390 // Settings
391 - .route("/api/internal/creator/ssh-keys", get(internal::list_ssh_keys));
391 + .route("/api/internal/creator/ssh-keys", get(internal::list_ssh_keys))
392 + // Git authorization
393 + .route("/api/internal/git/authorize", post(internal::git_authorize));
392 394
393 395 write_routes
394 396 .merge(export_routes)