//! Internal git repository and SSH key management for the CLI. //! //! These verbs used to live only in [`crate::git_ssh`], reachable through //! sshd's `command=` prefix in `authorized_keys` calling `mnw-admin git-auth`. //! The git transport moved to mnw-cli's russh server and the management //! commands did not come with it, so `ssh cli.makenot.work repo list` answered //! "Unknown command: repo" while the implementation sat in the tree, working, //! behind a door nothing knocked on. That gap is how an accidental push //! published a repo with no supported way to unpublish it. //! //! Repos are addressed by NAME here rather than by id. The browser API keys on //! `GitRepoId` because a page has already loaded the row it is acting on; a //! person typing into a terminal has the name and nothing else, and making them //! look a UUID up first would be the kind of friction that sends them back to //! the web UI. use axum::{ Json, extract::{Path, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::{ auth::{InternalActor, ServiceAuth}, db::{self, UserId, Visibility}, error::{AppError, Result}, validation::validate_git_repo_name, }; // --- Wire types --- /// One repository, as the CLI renders it in `repo list`. #[derive(Serialize)] pub(super) struct CliRepo { name: String, visibility: Visibility, description: String, created_at: String, } /// A single repository plus the counts `repo info` prints. #[derive(Serialize)] pub(super) struct CliRepoInfo { name: String, visibility: Visibility, description: String, created_at: String, open_issues: i64, closed_issues: i64, } #[derive(Deserialize)] pub(super) struct SetVisibilityRequest { pub visibility: Visibility, } #[derive(Deserialize)] pub(super) struct SetDescriptionRequest { pub description: String, } /// Resolve a repo by name for the acting user, or 404. /// /// The name is validated before the lookup rather than after. The query would /// simply miss on a bogus name, but `repo delete` builds a filesystem path from /// this same string, and rejecting traversal and control characters at the one /// place every verb passes through is cheaper than trusting each of them to /// remember. async fn resolve_repo(db: &PgPool, user_id: UserId, name: &str) -> Result { validate_git_repo_name(name).map_err(|_| AppError::NotFound)?; db::git_repos::get_repo_by_user_and_name(db, user_id, name) .await? .ok_or(AppError::NotFound) } // --- Repositories --- /// GET /api/internal/creator/repos #[tracing::instrument(skip_all, name = "internal::cli_repo_list")] pub(super) async fn repo_list( State(db): State, actor: InternalActor, _auth: ServiceAuth, ) -> Result { let repos = db::git_repos::get_repos_by_user(&db, actor.user_id()).await?; let data: Vec = repos .into_iter() .map(|r| CliRepo { name: r.name, visibility: r.visibility, description: r.description, created_at: r.created_at.format("%Y-%m-%d").to_string(), }) .collect(); Ok(Json(data)) } /// GET /api/internal/creator/repos/{name} #[tracing::instrument(skip_all, name = "internal::cli_repo_info")] pub(super) async fn repo_info( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(name): Path, ) -> Result { let repo = resolve_repo(&db, actor.user_id(), &name).await?; let (open_issues, closed_issues) = db::issues::get_issue_counts(&db, repo.id).await?; Ok(Json(CliRepoInfo { name: repo.name, visibility: repo.visibility, description: repo.description, created_at: repo.created_at.format("%Y-%m-%d %H:%M UTC").to_string(), open_issues, closed_issues, })) } /// PUT /api/internal/creator/repos/{name}/visibility #[tracing::instrument(skip_all, name = "internal::cli_repo_set_visibility")] pub(super) async fn repo_set_visibility( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(name): Path, Json(req): Json, ) -> Result { let repo = resolve_repo(&db, actor.user_id(), &name).await?; // Private permanently: publishing a set of annotations is a moderation and // consent decision, not a toggle. if db::git_repos::is_annotation_repo(&repo) && req.visibility != db::Visibility::Private { return Err(AppError::validation( "Your annotations repository is private permanently.".to_string(), )); } db::git_repos::update_visibility(&db, repo.id, req.visibility).await?; Ok(Json(serde_json::json!({ "visibility": req.visibility }))) } /// PUT /api/internal/creator/repos/{name}/description #[tracing::instrument(skip_all, name = "internal::cli_repo_set_description")] pub(super) async fn repo_set_description( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(name): Path, Json(req): Json, ) -> Result { let repo = resolve_repo(&db, actor.user_id(), &name).await?; db::git_repos::update_repo_settings(&db, repo.id, &req.description, repo.visibility).await?; Ok(Json(serde_json::json!({ "description": req.description }))) } /// DELETE /api/internal/creator/repos/{name} /// /// Drops the row and then the bare repo on disk. The row goes first: a failure /// to remove the directory leaves an orphaned directory, which is recoverable, /// where the other order could leave a row pointing at nothing, which reads as /// a repo that exists and cannot be served. #[tracing::instrument(skip_all, name = "internal::cli_repo_delete")] pub(super) async fn repo_delete( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(name): Path, ) -> Result { let repo = resolve_repo(&db, actor.user_id(), &name).await?; let username = db::users::get_user_by_id(&db, actor.user_id()) .await? .ok_or(AppError::NotFound)? .username; db::git_repos::delete_repo(&db, repo.id).await?; let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()); let git_root_path = std::path::Path::new(&git_root); let repo_dir = git_root_path .join(username.as_str()) .join(format!("{name}.git")); if repo_dir.exists() { // Canonicalize both sides and re-check containment. `name` is already // validated, so this is belt and braces, but the operation is a // recursive delete and the cost of being wrong is unbounded. let canonical = repo_dir .canonicalize() .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize repo path: {e}")))?; let canonical_root = git_root_path .canonicalize() .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize git root: {e}")))?; if !canonical.starts_with(&canonical_root) { return Err(AppError::Internal(anyhow::anyhow!( "repo path escapes git root" ))); } std::fs::remove_dir_all(&canonical) .map_err(|e| AppError::Internal(anyhow::anyhow!("remove repo directory: {e}")))?; } Ok(Json(serde_json::json!({ "deleted": name }))) } // --- SSH keys --- // `key list` is served by the pre-existing `git::list_ssh_keys` on this same // path; adding a second handler for it collided at router build time. Only the // removal verb is new. /// DELETE /api/internal/creator/ssh-keys/{fingerprint} /// /// Rewrites `authorized_keys` after the row is gone, exactly as the old /// `key rm` did. mnw-cli authenticates from the database and does not read that /// file, so this is not what makes the removal take effect; it is there because /// the sshd path may still be wired on a host, and a key removed from one door /// but not the other is a key the user believes is gone. A failure to rewrite is /// logged rather than returned: the authoritative removal already succeeded, and /// reporting failure would invite a retry that cannot help. #[tracing::instrument(skip_all, name = "internal::cli_key_remove")] pub(super) async fn key_remove( State(db): State, actor: InternalActor, _auth: ServiceAuth, Path(fingerprint): Path, ) -> Result { let deleted = db::ssh_keys::delete_key_by_fingerprint(&db, actor.user_id(), &fingerprint).await?; if !deleted { return Err(AppError::NotFound); } if let Err(e) = crate::git_ssh::write_authorized_keys(&db, false).await { tracing::warn!( error = %e, "SSH key removed from the database but authorized_keys could not be rewritten" ); } Ok(Json(serde_json::json!({ "removed": fingerprint }))) }