Skip to main content

max / makenotwork

8.5 KB · 235 lines History Blame Raw
1 //! Internal git repository and SSH key management for the CLI.
2 //!
3 //! These verbs used to live only in [`crate::git_ssh`], reachable through
4 //! sshd's `command=` prefix in `authorized_keys` calling `mnw-admin git-auth`.
5 //! The git transport moved to mnw-cli's russh server and the management
6 //! commands did not come with it, so `ssh cli.makenot.work repo list` answered
7 //! "Unknown command: repo" while the implementation sat in the tree, working,
8 //! behind a door nothing knocked on. That gap is how an accidental push
9 //! published a repo with no supported way to unpublish it.
10 //!
11 //! Repos are addressed by NAME here rather than by id. The browser API keys on
12 //! `GitRepoId` because a page has already loaded the row it is acting on; a
13 //! person typing into a terminal has the name and nothing else, and making them
14 //! look a UUID up first would be the kind of friction that sends them back to
15 //! the web UI.
16
17 use axum::{
18 Json,
19 extract::{Path, State},
20 response::IntoResponse,
21 };
22 use serde::{Deserialize, Serialize};
23 use sqlx::PgPool;
24
25 use crate::{
26 auth::{InternalActor, ServiceAuth},
27 db::{self, UserId, Visibility},
28 error::{AppError, Result},
29 validation::validate_git_repo_name,
30 };
31
32 // --- Wire types ---
33
34 /// One repository, as the CLI renders it in `repo list`.
35 #[derive(Serialize)]
36 pub(super) struct CliRepo {
37 name: String,
38 visibility: Visibility,
39 description: String,
40 created_at: String,
41 }
42
43 /// A single repository plus the counts `repo info` prints.
44 #[derive(Serialize)]
45 pub(super) struct CliRepoInfo {
46 name: String,
47 visibility: Visibility,
48 description: String,
49 created_at: String,
50 open_issues: i64,
51 closed_issues: i64,
52 }
53
54 #[derive(Deserialize)]
55 pub(super) struct SetVisibilityRequest {
56 pub visibility: Visibility,
57 }
58
59 #[derive(Deserialize)]
60 pub(super) struct SetDescriptionRequest {
61 pub description: String,
62 }
63
64 /// Resolve a repo by name for the acting user, or 404.
65 ///
66 /// The name is validated before the lookup rather than after. The query would
67 /// simply miss on a bogus name, but `repo delete` builds a filesystem path from
68 /// this same string, and rejecting traversal and control characters at the one
69 /// place every verb passes through is cheaper than trusting each of them to
70 /// remember (ultra-fuzz Sec M2).
71 async fn resolve_repo(db: &PgPool, user_id: UserId, name: &str) -> Result<db::DbGitRepo> {
72 validate_git_repo_name(name).map_err(|_| AppError::NotFound)?;
73 db::git_repos::get_repo_by_user_and_name(db, user_id, name)
74 .await?
75 .ok_or(AppError::NotFound)
76 }
77
78 // --- Repositories ---
79
80 /// GET /api/internal/creator/repos
81 #[tracing::instrument(skip_all, name = "internal::cli_repo_list")]
82 pub(super) async fn repo_list(
83 State(db): State<PgPool>,
84 actor: InternalActor,
85 _auth: ServiceAuth,
86 ) -> Result<impl IntoResponse> {
87 let repos = db::git_repos::get_repos_by_user(&db, actor.user_id()).await?;
88 let data: Vec<CliRepo> = repos
89 .into_iter()
90 .map(|r| CliRepo {
91 name: r.name,
92 visibility: r.visibility,
93 description: r.description,
94 created_at: r.created_at.format("%Y-%m-%d").to_string(),
95 })
96 .collect();
97 Ok(Json(data))
98 }
99
100 /// GET /api/internal/creator/repos/{name}
101 #[tracing::instrument(skip_all, name = "internal::cli_repo_info")]
102 pub(super) async fn repo_info(
103 State(db): State<PgPool>,
104 actor: InternalActor,
105 _auth: ServiceAuth,
106 Path(name): Path<String>,
107 ) -> Result<impl IntoResponse> {
108 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
109 let (open_issues, closed_issues) = db::issues::get_issue_counts(&db, repo.id).await?;
110
111 Ok(Json(CliRepoInfo {
112 name: repo.name,
113 visibility: repo.visibility,
114 description: repo.description,
115 created_at: repo.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
116 open_issues,
117 closed_issues,
118 }))
119 }
120
121 /// PUT /api/internal/creator/repos/{name}/visibility
122 #[tracing::instrument(skip_all, name = "internal::cli_repo_set_visibility")]
123 pub(super) async fn repo_set_visibility(
124 State(db): State<PgPool>,
125 actor: InternalActor,
126 _auth: ServiceAuth,
127 Path(name): Path<String>,
128 Json(req): Json<SetVisibilityRequest>,
129 ) -> Result<impl IntoResponse> {
130 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
131 db::git_repos::update_visibility(&db, repo.id, req.visibility).await?;
132 Ok(Json(serde_json::json!({ "visibility": req.visibility })))
133 }
134
135 /// PUT /api/internal/creator/repos/{name}/description
136 #[tracing::instrument(skip_all, name = "internal::cli_repo_set_description")]
137 pub(super) async fn repo_set_description(
138 State(db): State<PgPool>,
139 actor: InternalActor,
140 _auth: ServiceAuth,
141 Path(name): Path<String>,
142 Json(req): Json<SetDescriptionRequest>,
143 ) -> Result<impl IntoResponse> {
144 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
145 db::git_repos::update_repo_settings(&db, repo.id, &req.description, repo.visibility).await?;
146 Ok(Json(serde_json::json!({ "description": req.description })))
147 }
148
149 /// DELETE /api/internal/creator/repos/{name}
150 ///
151 /// Drops the row and then the bare repo on disk. The row goes first: a failure
152 /// to remove the directory leaves an orphaned directory, which is recoverable,
153 /// where the other order could leave a row pointing at nothing, which reads as
154 /// a repo that exists and cannot be served.
155 #[tracing::instrument(skip_all, name = "internal::cli_repo_delete")]
156 pub(super) async fn repo_delete(
157 State(db): State<PgPool>,
158 actor: InternalActor,
159 _auth: ServiceAuth,
160 Path(name): Path<String>,
161 ) -> Result<impl IntoResponse> {
162 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
163 let username = db::users::get_user_by_id(&db, actor.user_id())
164 .await?
165 .ok_or(AppError::NotFound)?
166 .username;
167
168 db::git_repos::delete_repo(&db, repo.id).await?;
169
170 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
171 let git_root_path = std::path::Path::new(&git_root);
172 let repo_dir = git_root_path
173 .join(username.as_str())
174 .join(format!("{name}.git"));
175
176 if repo_dir.exists() {
177 // Canonicalize both sides and re-check containment. `name` is already
178 // validated, so this is belt and braces, but the operation is a
179 // recursive delete and the cost of being wrong is unbounded.
180 let canonical = repo_dir
181 .canonicalize()
182 .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize repo path: {e}")))?;
183 let canonical_root = git_root_path
184 .canonicalize()
185 .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize git root: {e}")))?;
186 if !canonical.starts_with(&canonical_root) {
187 return Err(AppError::Internal(anyhow::anyhow!(
188 "repo path escapes git root"
189 )));
190 }
191 std::fs::remove_dir_all(&canonical)
192 .map_err(|e| AppError::Internal(anyhow::anyhow!("remove repo directory: {e}")))?;
193 }
194
195 Ok(Json(serde_json::json!({ "deleted": name })))
196 }
197
198 // --- SSH keys ---
199
200 // `key list` is served by the pre-existing `git::list_ssh_keys` on this same
201 // path; adding a second handler for it collided at router build time. Only the
202 // removal verb is new.
203
204 /// DELETE /api/internal/creator/ssh-keys/{fingerprint}
205 ///
206 /// Rewrites `authorized_keys` after the row is gone, exactly as the old
207 /// `key rm` did. mnw-cli authenticates from the database and does not read that
208 /// file, so this is not what makes the removal take effect; it is there because
209 /// the sshd path may still be wired on a host, and a key removed from one door
210 /// but not the other is a key the user believes is gone. A failure to rewrite is
211 /// logged rather than returned: the authoritative removal already succeeded, and
212 /// reporting failure would invite a retry that cannot help.
213 #[tracing::instrument(skip_all, name = "internal::cli_key_remove")]
214 pub(super) async fn key_remove(
215 State(db): State<PgPool>,
216 actor: InternalActor,
217 _auth: ServiceAuth,
218 Path(fingerprint): Path<String>,
219 ) -> Result<impl IntoResponse> {
220 let deleted =
221 db::ssh_keys::delete_key_by_fingerprint(&db, actor.user_id(), &fingerprint).await?;
222 if !deleted {
223 return Err(AppError::NotFound);
224 }
225
226 if let Err(e) = crate::git_ssh::write_authorized_keys(&db, false).await {
227 tracing::warn!(
228 error = %e,
229 "SSH key removed from the database but authorized_keys could not be rewritten"
230 );
231 }
232
233 Ok(Json(serde_json::json!({ "removed": fingerprint })))
234 }
235