//! Internal git service: SSH key lookup, git push authorization, and server restart control. use axum::{ extract::{Query, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use std::sync::atomic::Ordering; use crate::{ auth::ServiceAuth, db::{self, CreatorTier, UserId, Username, Visibility}, error::{AppError, Result}, AppState, }; // ── SSH key lookup ── #[derive(Deserialize)] pub(super) struct SshKeyLookupQuery { fingerprint: String, } #[derive(Serialize)] struct SshKeyLookupResponse { user_id: UserId, username: Username, display_name: Option, creator_tier: Option, can_create_projects: bool, suspended: bool, } /// GET /api/internal/ssh-key-lookup?fingerprint={sha256} /// /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not. #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")] pub(super) async fn ssh_key_lookup( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let user = db::ssh_keys::lookup_user_by_fingerprint(&state.db, &query.fingerprint) .await? .ok_or(AppError::NotFound)?; Ok(Json(SshKeyLookupResponse { user_id: user.user_id, username: user.username, display_name: user.display_name, creator_tier: user.creator_tier, can_create_projects: user.can_create_projects, suspended: user.suspended, })) } // ── SSH keys ── #[derive(Deserialize)] pub(super) struct UserIdQuery { user_id: UserId, } #[derive(Serialize)] struct SshKeyResponse { id: String, label: String, fingerprint: String, created_at: String, } /// GET /api/internal/creator/ssh-keys?user_id={uuid} /// /// List registered SSH keys for a user. #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")] pub(super) async fn list_ssh_keys( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&state.db, query.user_id).await?; let data: Vec = keys .into_iter() .map(|k| SshKeyResponse { id: k.id.to_string(), label: k.label, fingerprint: k.fingerprint, created_at: k.created_at.to_rfc3339(), }) .collect(); Ok(Json(data)) } // ── Git authorization ── #[derive(Deserialize)] pub(super) struct GitAuthorizeRequest { user_id: UserId, /// "git-upload-pack", "git-receive-pack", or "git-upload-archive" operation: String, owner: String, repo_name: String, } #[derive(Serialize)] struct GitAuthorizeResponse { repo_path: String, } /// POST /api/internal/git/authorize /// /// Authorize a git operation and return the on-disk repo path. /// Auto-creates bare repos on first push if the authenticated user owns the namespace. #[tracing::instrument(skip_all, name = "internal::git_authorize")] pub(super) async fn git_authorize( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let git_root = state .config .git_repos_path .as_deref() .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?; // Look up the namespace owner let owner_user = db::users::get_user_by_username( &state.db, &Username::from_trusted(req.owner.clone()), ) .await? .ok_or(AppError::NotFound)?; let repo = match db::git_repos::get_repo_by_user_and_name( &state.db, owner_user.id, &req.repo_name, ) .await? { Some(repo) => repo, None => { // Auto-create on push if the authenticated user owns the namespace. // Only register in the DB here — mnw-cli creates the bare repo on // disk as the git user (avoids ownership/privilege issues). if req.operation != "git-receive-pack" || req.user_id != owner_user.id { return Err(AppError::NotFound); } tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository"); db::git_repos::create_repo(&state.db, owner_user.id, &req.repo_name).await? } }; // Permission check match req.operation.as_str() { "git-receive-pack" => { if req.user_id != owner_user.id { return Err(AppError::Forbidden); } } "git-upload-pack" | "git-upload-archive" => { if repo.visibility == Visibility::Private && req.user_id != owner_user.id { return Err(AppError::NotFound); } } _ => return Err(AppError::BadRequest("unsupported git operation".into())), } let repo_path = std::path::Path::new(git_root) .join(&req.owner) .join(format!("{}.git", req.repo_name)); Ok(Json(GitAuthorizeResponse { repo_path: repo_path.to_string_lossy().into_owned(), })) } // ── Restart warning ── #[derive(Deserialize)] pub(super) struct RestartWarningRequest { seconds: i64, } /// POST /api/internal/restart-warning /// /// Set a pending restart timestamp. `{"seconds": 30}` means "restart in 30s". /// `{"seconds": 0}` cancels any pending warning. #[tracing::instrument(skip_all, name = "internal::set_restart_warning")] pub(super) async fn set_restart_warning( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let ts = if req.seconds > 0 { chrono::Utc::now().timestamp() + req.seconds } else { 0 }; state.restart_at.store(ts, Ordering::Relaxed); tracing::info!(restart_at = ts, seconds = req.seconds, "restart warning set"); Ok(axum::http::StatusCode::NO_CONTENT) } /// GET /api/restart-status /// /// Public, unauthenticated. Returns the pending restart timestamp (or null). /// Single atomic load, no DB, no session. pub(in crate::routes::api) async fn restart_status( State(state): State, ) -> impl IntoResponse { let ts = state.restart_at.load(Ordering::Relaxed); let restart_at = if ts > 0 { Some(ts) } else { None }; Json(serde_json::json!({ "restart_at": restart_at })) }