Skip to main content

max / makenotwork

8.8 KB · 242 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.
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 // Private permanently: publishing a set of annotations is a moderation and
132 // consent decision, not a toggle.
133 if db::git_repos::is_annotation_repo(&repo) && req.visibility != db::Visibility::Private {
134 return Err(AppError::validation(
135 "Your annotations repository is private permanently.".to_string(),
136 ));
137 }
138 db::git_repos::update_visibility(&db, repo.id, req.visibility).await?;
139 Ok(Json(serde_json::json!({ "visibility": req.visibility })))
140 }
141
142 /// PUT /api/internal/creator/repos/{name}/description
143 #[tracing::instrument(skip_all, name = "internal::cli_repo_set_description")]
144 pub(super) async fn repo_set_description(
145 State(db): State<PgPool>,
146 actor: InternalActor,
147 _auth: ServiceAuth,
148 Path(name): Path<String>,
149 Json(req): Json<SetDescriptionRequest>,
150 ) -> Result<impl IntoResponse> {
151 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
152 db::git_repos::update_repo_settings(&db, repo.id, &req.description, repo.visibility).await?;
153 Ok(Json(serde_json::json!({ "description": req.description })))
154 }
155
156 /// DELETE /api/internal/creator/repos/{name}
157 ///
158 /// Drops the row and then the bare repo on disk. The row goes first: a failure
159 /// to remove the directory leaves an orphaned directory, which is recoverable,
160 /// where the other order could leave a row pointing at nothing, which reads as
161 /// a repo that exists and cannot be served.
162 #[tracing::instrument(skip_all, name = "internal::cli_repo_delete")]
163 pub(super) async fn repo_delete(
164 State(db): State<PgPool>,
165 actor: InternalActor,
166 _auth: ServiceAuth,
167 Path(name): Path<String>,
168 ) -> Result<impl IntoResponse> {
169 let repo = resolve_repo(&db, actor.user_id(), &name).await?;
170 let username = db::users::get_user_by_id(&db, actor.user_id())
171 .await?
172 .ok_or(AppError::NotFound)?
173 .username;
174
175 db::git_repos::delete_repo(&db, repo.id).await?;
176
177 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
178 let git_root_path = std::path::Path::new(&git_root);
179 let repo_dir = git_root_path
180 .join(username.as_str())
181 .join(format!("{name}.git"));
182
183 if repo_dir.exists() {
184 // Canonicalize both sides and re-check containment. `name` is already
185 // validated, so this is belt and braces, but the operation is a
186 // recursive delete and the cost of being wrong is unbounded.
187 let canonical = repo_dir
188 .canonicalize()
189 .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize repo path: {e}")))?;
190 let canonical_root = git_root_path
191 .canonicalize()
192 .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize git root: {e}")))?;
193 if !canonical.starts_with(&canonical_root) {
194 return Err(AppError::Internal(anyhow::anyhow!(
195 "repo path escapes git root"
196 )));
197 }
198 std::fs::remove_dir_all(&canonical)
199 .map_err(|e| AppError::Internal(anyhow::anyhow!("remove repo directory: {e}")))?;
200 }
201
202 Ok(Json(serde_json::json!({ "deleted": name })))
203 }
204
205 // --- SSH keys ---
206
207 // `key list` is served by the pre-existing `git::list_ssh_keys` on this same
208 // path; adding a second handler for it collided at router build time. Only the
209 // removal verb is new.
210
211 /// DELETE /api/internal/creator/ssh-keys/{fingerprint}
212 ///
213 /// Rewrites `authorized_keys` after the row is gone, exactly as the old
214 /// `key rm` did. mnw-cli authenticates from the database and does not read that
215 /// file, so this is not what makes the removal take effect; it is there because
216 /// the sshd path may still be wired on a host, and a key removed from one door
217 /// but not the other is a key the user believes is gone. A failure to rewrite is
218 /// logged rather than returned: the authoritative removal already succeeded, and
219 /// reporting failure would invite a retry that cannot help.
220 #[tracing::instrument(skip_all, name = "internal::cli_key_remove")]
221 pub(super) async fn key_remove(
222 State(db): State<PgPool>,
223 actor: InternalActor,
224 _auth: ServiceAuth,
225 Path(fingerprint): Path<String>,
226 ) -> Result<impl IntoResponse> {
227 let deleted =
228 db::ssh_keys::delete_key_by_fingerprint(&db, actor.user_id(), &fingerprint).await?;
229 if !deleted {
230 return Err(AppError::NotFound);
231 }
232
233 if let Err(e) = crate::git_ssh::write_authorized_keys(&db, false).await {
234 tracing::warn!(
235 error = %e,
236 "SSH key removed from the database but authorized_keys could not be rewritten"
237 );
238 }
239
240 Ok(Json(serde_json::json!({ "removed": fingerprint })))
241 }
242