//! Internal git service: SSH key lookup, git push authorization, and server restart control. use crate::auth::InternalActor; use axum::{ Json, extract::{Query, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use std::sync::atomic::Ordering; use sqlx::PgPool; use crate::{ Ops, auth::ServiceAuth, config::Config, db::{self, CreatorTier, UserId, Username, Visibility}, error::{AppError, Result}, }; // ── 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, /// Signed actor assertion the CLI forwards (as `X-MNW-Actor`) on subsequent /// internal calls so the server derives identity from an SSH-authenticated /// token, not a caller-supplied `user_id`. actor_token: String, /// Lowercase ISO code of the currency this creator settles in. The CLI /// renders every amount that is *theirs* — their revenue, their prices — in /// it. Per-project revenue carries its own `currency` and can differ, so /// this is the viewer's currency, not a claim about any given number. settlement_currency: crate::currency::SettlementCurrency, /// The creator's chosen console theme, as `makeover::ThemeSelection` /// encodes it: a bundled theme id, or `"system"` to follow the terminal. /// /// Absent where the creator has never chosen. The CLI's field is /// `#[serde(default)] Option` fed to `ThemeSelection::parse`, so /// absent, `null` and `"system"` all resolve to following the terminal — /// which is what a CLI talking to a server that predates this field does. /// /// Only ever an id makeover *embeds*: the CLI resolves against its embedded /// set, not a disk search path, so an id from anywhere else could not be /// honoured. `theming::normalize_console_theme` is what guarantees it. theme_id: Option, } /// 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(db): State, State(config): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let user = db::ssh_keys::lookup_user_by_fingerprint(&db, &query.fingerprint) .await? .ok_or(AppError::NotFound)?; // Mint an actor assertion for the resolved user; the CLI forwards it on the // session's internal calls. TTL comfortably exceeds any SSH session. let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS; let actor_token = crate::crypto::mint_internal_actor_token(user.user_id, expiry, &config.signing_secret); 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, actor_token, settlement_currency: user.settlement_currency, theme_id: user.console_theme, })) } // ── SSH keys ── #[derive(Deserialize)] pub(super) struct UserIdQuery {} #[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(db): State, actor: InternalActor, _auth: ServiceAuth, Query(_query): Query, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&db, actor.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 { /// "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(db): State, State(config): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let git_root = config.build.git_repos_path.as_deref().ok_or_else(|| { AppError::ServiceUnavailable("Git hosting is not configured".to_string()) })?; // Look up the namespace owner. `req.owner` is a client-supplied field, so // validate it through `Username::new` rather than `from_trusted`, the // newtype's contract is "this string already passed validation", and an // unvalidated owner defeats it (a malformed owner can't name a real // user, so it maps to the same NotFound). let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?; let owner_user = db::users::get_user_by_username(&db, &owner) .await? .ok_or(AppError::NotFound)?; let repo = match db::git_repos::get_repo_by_user_and_name(&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" || actor.user_id() != owner_user.id { return Err(AppError::NotFound); } tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository"); // Concurrent double-push can race two auto-registers; on the loser's // unique violation, re-resolve instead of 500ing the git client, the // same pattern the smart-HTTP path uses (ultra-fuzz Run 12 Storage). match db::git_repos::create_repo(&db, owner_user.id, &req.repo_name).await { Ok(r) => r, Err(e) => { tracing::debug!(owner = %req.owner, repo = %req.repo_name, error = ?e, "auto-register failed, retrying lookup"); db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name) .await? .ok_or(AppError::NotFound)? } } } }; // Permission check match req.operation.as_str() { "git-receive-pack" => { if actor.user_id() != owner_user.id { return Err(AppError::Forbidden); } } "git-upload-pack" | "git-upload-archive" => { if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id { return Err(AppError::NotFound); } } _ => return Err(AppError::BadRequest("unsupported git operation".into())), } // Validate the path segments before building a filesystem path. `owner` is // already validated upstream, but `repo_name` reached the `join` unchecked // (fuzz 2026-07-06 L2); validate both charset-wise (no `/`, `..`, leading // dot) so a crafted name cannot escape the git root even if a future caller // skips the DB-row check that currently blocks traversal transitively. crate::git::validate_segment(&req.owner) .and_then(|()| crate::git::validate_segment(&req.repo_name)) .map_err(|_| AppError::BadRequest("invalid repository name".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(ops): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let ts = if req.seconds > 0 { chrono::Utc::now().timestamp() + req.seconds } else { 0 }; ops.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(ops): State) -> impl IntoResponse { let ts = ops.restart_at.load(Ordering::Relaxed); let restart_at = if ts > 0 { Some(ts) } else { None }; Json(serde_json::json!({ "restart_at": restart_at })) }