Skip to main content

max / makenotwork

6.3 KB · 219 lines History Blame Raw
1 //! Internal git service: SSH key lookup, git push authorization, and server restart control.
2
3 use axum::{
4 extract::{Query, State},
5 response::IntoResponse,
6 Json,
7 };
8 use serde::{Deserialize, Serialize};
9 use std::sync::atomic::Ordering;
10
11 use crate::{
12 auth::ServiceAuth,
13 db::{self, CreatorTier, UserId, Username, Visibility},
14 error::{AppError, Result},
15 AppState,
16 };
17
18 // ── SSH key lookup ──
19
20 #[derive(Deserialize)]
21 pub(super) struct SshKeyLookupQuery {
22 fingerprint: String,
23 }
24
25 #[derive(Serialize)]
26 struct SshKeyLookupResponse {
27 user_id: UserId,
28 username: Username,
29 display_name: Option<String>,
30 creator_tier: Option<CreatorTier>,
31 can_create_projects: bool,
32 suspended: bool,
33 }
34
35 /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
36 ///
37 /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
38 #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
39 pub(super) async fn ssh_key_lookup(
40 State(state): State<AppState>,
41 _auth: ServiceAuth,
42 Query(query): Query<SshKeyLookupQuery>,
43 ) -> Result<impl IntoResponse> {
44 let user = db::ssh_keys::lookup_user_by_fingerprint(&state.db, &query.fingerprint)
45 .await?
46 .ok_or(AppError::NotFound)?;
47
48 Ok(Json(SshKeyLookupResponse {
49 user_id: user.user_id,
50 username: user.username,
51 display_name: user.display_name,
52 creator_tier: user.creator_tier,
53 can_create_projects: user.can_create_projects,
54 suspended: user.suspended,
55 }))
56 }
57
58 // ── SSH keys ──
59
60 #[derive(Deserialize)]
61 pub(super) struct UserIdQuery {
62 user_id: UserId,
63 }
64
65 #[derive(Serialize)]
66 struct SshKeyResponse {
67 id: String,
68 label: String,
69 fingerprint: String,
70 created_at: String,
71 }
72
73 /// GET /api/internal/creator/ssh-keys?user_id={uuid}
74 ///
75 /// List registered SSH keys for a user.
76 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
77 pub(super) async fn list_ssh_keys(
78 State(state): State<AppState>,
79 _auth: ServiceAuth,
80 Query(query): Query<UserIdQuery>,
81 ) -> Result<impl IntoResponse> {
82 let keys = db::ssh_keys::list_keys_by_user(&state.db, query.user_id).await?;
83 let data: Vec<SshKeyResponse> = keys
84 .into_iter()
85 .map(|k| SshKeyResponse {
86 id: k.id.to_string(),
87 label: k.label,
88 fingerprint: k.fingerprint,
89 created_at: k.created_at.to_rfc3339(),
90 })
91 .collect();
92
93 Ok(Json(data))
94 }
95
96 // ── Git authorization ──
97
98 #[derive(Deserialize)]
99 pub(super) struct GitAuthorizeRequest {
100 user_id: UserId,
101 /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
102 operation: String,
103 owner: String,
104 repo_name: String,
105 }
106
107 #[derive(Serialize)]
108 struct GitAuthorizeResponse {
109 repo_path: String,
110 }
111
112 /// POST /api/internal/git/authorize
113 ///
114 /// Authorize a git operation and return the on-disk repo path.
115 /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
116 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
117 pub(super) async fn git_authorize(
118 State(state): State<AppState>,
119 _auth: ServiceAuth,
120 Json(req): Json<GitAuthorizeRequest>,
121 ) -> Result<impl IntoResponse> {
122 let git_root = state
123 .config
124 .git_repos_path
125 .as_deref()
126 .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?;
127
128 // Look up the namespace owner
129 let owner_user = db::users::get_user_by_username(
130 &state.db,
131 &Username::from_trusted(req.owner.clone()),
132 )
133 .await?
134 .ok_or(AppError::NotFound)?;
135
136 let repo = match db::git_repos::get_repo_by_user_and_name(
137 &state.db,
138 owner_user.id,
139 &req.repo_name,
140 )
141 .await?
142 {
143 Some(repo) => repo,
144 None => {
145 // Auto-create on push if the authenticated user owns the namespace.
146 // Only register in the DB here — mnw-cli creates the bare repo on
147 // disk as the git user (avoids ownership/privilege issues).
148 if req.operation != "git-receive-pack" || req.user_id != owner_user.id {
149 return Err(AppError::NotFound);
150 }
151
152 tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository");
153 db::git_repos::create_repo(&state.db, owner_user.id, &req.repo_name).await?
154 }
155 };
156
157 // Permission check
158 match req.operation.as_str() {
159 "git-receive-pack" => {
160 if req.user_id != owner_user.id {
161 return Err(AppError::Forbidden);
162 }
163 }
164 "git-upload-pack" | "git-upload-archive" => {
165 if repo.visibility == Visibility::Private && req.user_id != owner_user.id {
166 return Err(AppError::NotFound);
167 }
168 }
169 _ => return Err(AppError::BadRequest("unsupported git operation".into())),
170 }
171
172 let repo_path = std::path::Path::new(git_root)
173 .join(&req.owner)
174 .join(format!("{}.git", req.repo_name));
175
176 Ok(Json(GitAuthorizeResponse {
177 repo_path: repo_path.to_string_lossy().into_owned(),
178 }))
179 }
180
181 // ── Restart warning ──
182
183 #[derive(Deserialize)]
184 pub(super) struct RestartWarningRequest {
185 seconds: i64,
186 }
187
188 /// POST /api/internal/restart-warning
189 ///
190 /// Set a pending restart timestamp. `{"seconds": 30}` means "restart in 30s".
191 /// `{"seconds": 0}` cancels any pending warning.
192 #[tracing::instrument(skip_all, name = "internal::set_restart_warning")]
193 pub(super) async fn set_restart_warning(
194 State(state): State<AppState>,
195 _auth: ServiceAuth,
196 Json(req): Json<RestartWarningRequest>,
197 ) -> Result<impl IntoResponse> {
198 let ts = if req.seconds > 0 {
199 chrono::Utc::now().timestamp() + req.seconds
200 } else {
201 0
202 };
203 state.restart_at.store(ts, Ordering::Relaxed);
204 tracing::info!(restart_at = ts, seconds = req.seconds, "restart warning set");
205 Ok(axum::http::StatusCode::NO_CONTENT)
206 }
207
208 /// GET /api/restart-status
209 ///
210 /// Public, unauthenticated. Returns the pending restart timestamp (or null).
211 /// Single atomic load, no DB, no session.
212 pub(in crate::routes::api) async fn restart_status(
213 State(state): State<AppState>,
214 ) -> impl IntoResponse {
215 let ts = state.restart_at.load(Ordering::Relaxed);
216 let restart_at = if ts > 0 { Some(ts) } else { None };
217 Json(serde_json::json!({ "restart_at": restart_at }))
218 }
219