Skip to main content

max / makenotwork

9.7 KB · 273 lines History Blame Raw
1 //! Internal git service: SSH key lookup, git push authorization, and server restart control.
2
3 use crate::auth::InternalActor;
4 use axum::{
5 Json,
6 extract::{Query, State},
7 response::IntoResponse,
8 };
9 use serde::{Deserialize, Serialize};
10 use std::sync::atomic::Ordering;
11
12 use sqlx::PgPool;
13
14 use crate::{
15 Ops,
16 auth::ServiceAuth,
17 config::Config,
18 db::{self, CreatorTier, UserId, Username, Visibility},
19 error::{AppError, Result},
20 };
21
22 // --- SSH key lookup ---
23
24 #[derive(Deserialize)]
25 pub(super) struct SshKeyLookupQuery {
26 fingerprint: String,
27 }
28
29 #[derive(Serialize)]
30 struct SshKeyLookupResponse {
31 user_id: UserId,
32 username: Username,
33 display_name: Option<String>,
34 creator_tier: Option<CreatorTier>,
35 can_create_projects: bool,
36 suspended: bool,
37 /// Signed actor assertion the CLI forwards (as `X-MNW-Actor`) on subsequent
38 /// internal calls so the server derives identity from an SSH-authenticated
39 /// token, not a caller-supplied `user_id`.
40 actor_token: String,
41 /// Lowercase ISO code of the currency this creator settles in. The CLI
42 /// renders every amount that is *theirs* — their revenue, their prices — in
43 /// it. Per-project revenue carries its own `currency` and can differ, so
44 /// this is the viewer's currency, not a claim about any given number.
45 settlement_currency: crate::currency::SettlementCurrency,
46 /// The creator's chosen console theme, as `makeover::ThemeSelection`
47 /// encodes it: a bundled theme id, or `"system"` to follow the terminal.
48 ///
49 /// Absent where the creator has never chosen. The CLI's field is
50 /// `#[serde(default)] Option<String>` fed to `ThemeSelection::parse`, so
51 /// absent, `null` and `"system"` all resolve to following the terminal —
52 /// which is what a CLI talking to a server that predates this field does.
53 ///
54 /// Only ever an id makeover *embeds*: the CLI resolves against its embedded
55 /// set, not a disk search path, so an id from anywhere else could not be
56 /// honoured. `theming::normalize_console_theme` is what guarantees it.
57 theme_id: Option<String>,
58 }
59
60 /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
61 ///
62 /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
63 #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
64 pub(super) async fn ssh_key_lookup(
65 State(db): State<PgPool>,
66 State(config): State<Config>,
67 _auth: ServiceAuth,
68 Query(query): Query<SshKeyLookupQuery>,
69 ) -> Result<impl IntoResponse> {
70 let user = db::ssh_keys::lookup_user_by_fingerprint(&db, &query.fingerprint)
71 .await?
72 .ok_or(AppError::NotFound)?;
73
74 // Mint an actor assertion for the resolved user; the CLI forwards it on the
75 // session's internal calls. TTL comfortably exceeds any SSH session.
76 let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS;
77 let actor_token =
78 crate::crypto::mint_internal_actor_token(user.user_id, expiry, &config.signing_secret);
79
80 Ok(Json(SshKeyLookupResponse {
81 user_id: user.user_id,
82 username: user.username,
83 display_name: user.display_name,
84 creator_tier: user.creator_tier,
85 can_create_projects: user.can_create_projects,
86 suspended: user.suspended,
87 actor_token,
88 settlement_currency: user.settlement_currency,
89 theme_id: user.console_theme,
90 }))
91 }
92
93 // --- SSH keys ---
94
95 #[derive(Deserialize)]
96 pub(super) struct UserIdQuery {}
97
98 #[derive(Serialize)]
99 struct SshKeyResponse {
100 id: String,
101 label: String,
102 fingerprint: String,
103 created_at: String,
104 }
105
106 /// GET /api/internal/creator/ssh-keys?user_id={uuid}
107 ///
108 /// List registered SSH keys for a user.
109 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
110 pub(super) async fn list_ssh_keys(
111 State(db): State<PgPool>,
112 actor: InternalActor,
113 _auth: ServiceAuth,
114 Query(_query): Query<UserIdQuery>,
115 ) -> Result<impl IntoResponse> {
116 let keys = db::ssh_keys::list_keys_by_user(&db, actor.user_id()).await?;
117 let data: Vec<SshKeyResponse> = keys
118 .into_iter()
119 .map(|k| SshKeyResponse {
120 id: k.id.to_string(),
121 label: k.label,
122 fingerprint: k.fingerprint,
123 created_at: k.created_at.to_rfc3339(),
124 })
125 .collect();
126
127 Ok(Json(data))
128 }
129
130 // --- Git authorization ---
131
132 #[derive(Deserialize)]
133 pub(super) struct GitAuthorizeRequest {
134 /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
135 operation: String,
136 owner: String,
137 repo_name: String,
138 }
139
140 #[derive(Serialize)]
141 struct GitAuthorizeResponse {
142 repo_path: String,
143 }
144
145 /// POST /api/internal/git/authorize
146 ///
147 /// Authorize a git operation and return the on-disk repo path.
148 /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
149 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
150 pub(super) async fn git_authorize(
151 State(db): State<PgPool>,
152 State(config): State<Config>,
153 actor: InternalActor,
154 _auth: ServiceAuth,
155 Json(req): Json<GitAuthorizeRequest>,
156 ) -> Result<impl IntoResponse> {
157 let git_root =
158 config.build.git_repos_path.as_deref().ok_or_else(|| {
159 AppError::ServiceUnavailable("Git hosting is not configured".to_string())
160 })?;
161
162 // Look up the namespace owner. `req.owner` is a client-supplied field, so
163 // validate it through `Username::new` rather than `from_trusted`, the
164 // newtype's contract is "this string already passed validation", and an
165 // unvalidated owner defeats it (a malformed owner can't name a real
166 // user, so it maps to the same NotFound).
167 let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?;
168 let owner_user = db::users::get_user_by_username(&db, &owner)
169 .await?
170 .ok_or(AppError::NotFound)?;
171
172 let repo = match db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
173 .await?
174 {
175 Some(repo) => repo,
176 None => {
177 // Auto-create on push if the authenticated user owns the namespace.
178 // Only register in the DB here, mnw-cli creates the bare repo on
179 // disk as the git user (avoids ownership/privilege issues).
180 if req.operation != "git-receive-pack" || actor.user_id() != owner_user.id {
181 return Err(AppError::NotFound);
182 }
183
184 tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository");
185 // Concurrent double-push can race two auto-registers; on the loser's
186 // unique violation, re-resolve instead of 500ing the git client, the
187 // same pattern the smart-HTTP path uses (ultra-fuzz Run 12 Storage).
188 match db::git_repos::create_repo(&db, owner_user.id, &req.repo_name).await {
189 Ok(r) => r,
190 Err(e) => {
191 tracing::debug!(owner = %req.owner, repo = %req.repo_name, error = ?e, "auto-register failed, retrying lookup");
192 db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
193 .await?
194 .ok_or(AppError::NotFound)?
195 }
196 }
197 }
198 };
199
200 // Permission check
201 match req.operation.as_str() {
202 "git-receive-pack" => {
203 if actor.user_id() != owner_user.id {
204 return Err(AppError::Forbidden);
205 }
206 }
207 "git-upload-pack" | "git-upload-archive" => {
208 if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id {
209 return Err(AppError::NotFound);
210 }
211 }
212 _ => return Err(AppError::BadRequest("unsupported git operation".into())),
213 }
214
215 // Validate the path segments before building a filesystem path. `owner` is
216 // already validated upstream, but `repo_name` reached the `join` unchecked
217 // (fuzz 2026-07-06 L2); validate both charset-wise (no `/`, `..`, leading
218 // dot) so a crafted name cannot escape the git root even if a future caller
219 // skips the DB-row check that currently blocks traversal transitively.
220 crate::git::validate_segment(&req.owner)
221 .and_then(|()| crate::git::validate_segment(&req.repo_name))
222 .map_err(|_| AppError::BadRequest("invalid repository name".into()))?;
223
224 let repo_path = std::path::Path::new(git_root)
225 .join(&req.owner)
226 .join(format!("{}.git", req.repo_name));
227
228 Ok(Json(GitAuthorizeResponse {
229 repo_path: repo_path.to_string_lossy().into_owned(),
230 }))
231 }
232
233 // --- Restart warning ---
234
235 #[derive(Deserialize)]
236 pub(super) struct RestartWarningRequest {
237 seconds: i64,
238 }
239
240 /// POST /api/internal/restart-warning
241 ///
242 /// Set a pending restart timestamp. `{"seconds": 30}` means "restart in 30s".
243 /// `{"seconds": 0}` cancels any pending warning.
244 #[tracing::instrument(skip_all, name = "internal::set_restart_warning")]
245 pub(super) async fn set_restart_warning(
246 State(ops): State<Ops>,
247 _auth: ServiceAuth,
248 Json(req): Json<RestartWarningRequest>,
249 ) -> Result<impl IntoResponse> {
250 let ts = if req.seconds > 0 {
251 chrono::Utc::now().timestamp() + req.seconds
252 } else {
253 0
254 };
255 ops.restart_at.store(ts, Ordering::Relaxed);
256 tracing::info!(
257 restart_at = ts,
258 seconds = req.seconds,
259 "restart warning set"
260 );
261 Ok(axum::http::StatusCode::NO_CONTENT)
262 }
263
264 /// GET /api/restart-status
265 ///
266 /// Public, unauthenticated. Returns the pending restart timestamp (or null).
267 /// Single atomic load, no DB, no session.
268 pub(in crate::routes::api) async fn restart_status(State(ops): State<Ops>) -> impl IntoResponse {
269 let ts = ops.restart_at.load(Ordering::Relaxed);
270 let restart_at = if ts > 0 { Some(ts) } else { None };
271 Json(serde_json::json!({ "restart_at": restart_at }))
272 }
273