Skip to main content

max / makenotwork

9.0 KB · 260 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 }
47
48 /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
49 ///
50 /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
51 #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
52 pub(super) async fn ssh_key_lookup(
53 State(db): State<PgPool>,
54 State(config): State<Config>,
55 _auth: ServiceAuth,
56 Query(query): Query<SshKeyLookupQuery>,
57 ) -> Result<impl IntoResponse> {
58 let user = db::ssh_keys::lookup_user_by_fingerprint(&db, &query.fingerprint)
59 .await?
60 .ok_or(AppError::NotFound)?;
61
62 // Mint an actor assertion for the resolved user; the CLI forwards it on the
63 // session's internal calls. TTL comfortably exceeds any SSH session.
64 let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS;
65 let actor_token =
66 crate::crypto::mint_internal_actor_token(user.user_id, expiry, &config.signing_secret);
67
68 Ok(Json(SshKeyLookupResponse {
69 user_id: user.user_id,
70 username: user.username,
71 display_name: user.display_name,
72 creator_tier: user.creator_tier,
73 can_create_projects: user.can_create_projects,
74 suspended: user.suspended,
75 actor_token,
76 settlement_currency: user.settlement_currency,
77 }))
78 }
79
80 // ── SSH keys ──
81
82 #[derive(Deserialize)]
83 pub(super) struct UserIdQuery {}
84
85 #[derive(Serialize)]
86 struct SshKeyResponse {
87 id: String,
88 label: String,
89 fingerprint: String,
90 created_at: String,
91 }
92
93 /// GET /api/internal/creator/ssh-keys?user_id={uuid}
94 ///
95 /// List registered SSH keys for a user.
96 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
97 pub(super) async fn list_ssh_keys(
98 State(db): State<PgPool>,
99 actor: InternalActor,
100 _auth: ServiceAuth,
101 Query(_query): Query<UserIdQuery>,
102 ) -> Result<impl IntoResponse> {
103 let keys = db::ssh_keys::list_keys_by_user(&db, actor.user_id()).await?;
104 let data: Vec<SshKeyResponse> = keys
105 .into_iter()
106 .map(|k| SshKeyResponse {
107 id: k.id.to_string(),
108 label: k.label,
109 fingerprint: k.fingerprint,
110 created_at: k.created_at.to_rfc3339(),
111 })
112 .collect();
113
114 Ok(Json(data))
115 }
116
117 // ── Git authorization ──
118
119 #[derive(Deserialize)]
120 pub(super) struct GitAuthorizeRequest {
121 /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
122 operation: String,
123 owner: String,
124 repo_name: String,
125 }
126
127 #[derive(Serialize)]
128 struct GitAuthorizeResponse {
129 repo_path: String,
130 }
131
132 /// POST /api/internal/git/authorize
133 ///
134 /// Authorize a git operation and return the on-disk repo path.
135 /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
136 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
137 pub(super) async fn git_authorize(
138 State(db): State<PgPool>,
139 State(config): State<Config>,
140 actor: InternalActor,
141 _auth: ServiceAuth,
142 Json(req): Json<GitAuthorizeRequest>,
143 ) -> Result<impl IntoResponse> {
144 let git_root =
145 config.build.git_repos_path.as_deref().ok_or_else(|| {
146 AppError::ServiceUnavailable("Git hosting is not configured".to_string())
147 })?;
148
149 // Look up the namespace owner. `req.owner` is a client-supplied field, so
150 // validate it through `Username::new` rather than `from_trusted`, the
151 // newtype's contract is "this string already passed validation", and an
152 // unvalidated owner defeats it (a malformed owner can't name a real
153 // user, so it maps to the same NotFound).
154 let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?;
155 let owner_user = db::users::get_user_by_username(&db, &owner)
156 .await?
157 .ok_or(AppError::NotFound)?;
158
159 let repo = match db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
160 .await?
161 {
162 Some(repo) => repo,
163 None => {
164 // Auto-create on push if the authenticated user owns the namespace.
165 // Only register in the DB here, mnw-cli creates the bare repo on
166 // disk as the git user (avoids ownership/privilege issues).
167 if req.operation != "git-receive-pack" || actor.user_id() != owner_user.id {
168 return Err(AppError::NotFound);
169 }
170
171 tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository");
172 // Concurrent double-push can race two auto-registers; on the loser's
173 // unique violation, re-resolve instead of 500ing the git client, the
174 // same pattern the smart-HTTP path uses (ultra-fuzz Run 12 Storage).
175 match db::git_repos::create_repo(&db, owner_user.id, &req.repo_name).await {
176 Ok(r) => r,
177 Err(e) => {
178 tracing::debug!(owner = %req.owner, repo = %req.repo_name, error = ?e, "auto-register failed, retrying lookup");
179 db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
180 .await?
181 .ok_or(AppError::NotFound)?
182 }
183 }
184 }
185 };
186
187 // Permission check
188 match req.operation.as_str() {
189 "git-receive-pack" => {
190 if actor.user_id() != owner_user.id {
191 return Err(AppError::Forbidden);
192 }
193 }
194 "git-upload-pack" | "git-upload-archive" => {
195 if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id {
196 return Err(AppError::NotFound);
197 }
198 }
199 _ => return Err(AppError::BadRequest("unsupported git operation".into())),
200 }
201
202 // Validate the path segments before building a filesystem path. `owner` is
203 // already validated upstream, but `repo_name` reached the `join` unchecked
204 // (fuzz 2026-07-06 L2); validate both charset-wise (no `/`, `..`, leading
205 // dot) so a crafted name cannot escape the git root even if a future caller
206 // skips the DB-row check that currently blocks traversal transitively.
207 crate::git::validate_segment(&req.owner)
208 .and_then(|()| crate::git::validate_segment(&req.repo_name))
209 .map_err(|_| AppError::BadRequest("invalid repository name".into()))?;
210
211 let repo_path = std::path::Path::new(git_root)
212 .join(&req.owner)
213 .join(format!("{}.git", req.repo_name));
214
215 Ok(Json(GitAuthorizeResponse {
216 repo_path: repo_path.to_string_lossy().into_owned(),
217 }))
218 }
219
220 // ── Restart warning ──
221
222 #[derive(Deserialize)]
223 pub(super) struct RestartWarningRequest {
224 seconds: i64,
225 }
226
227 /// POST /api/internal/restart-warning
228 ///
229 /// Set a pending restart timestamp. `{"seconds": 30}` means "restart in 30s".
230 /// `{"seconds": 0}` cancels any pending warning.
231 #[tracing::instrument(skip_all, name = "internal::set_restart_warning")]
232 pub(super) async fn set_restart_warning(
233 State(ops): State<Ops>,
234 _auth: ServiceAuth,
235 Json(req): Json<RestartWarningRequest>,
236 ) -> Result<impl IntoResponse> {
237 let ts = if req.seconds > 0 {
238 chrono::Utc::now().timestamp() + req.seconds
239 } else {
240 0
241 };
242 ops.restart_at.store(ts, Ordering::Relaxed);
243 tracing::info!(
244 restart_at = ts,
245 seconds = req.seconds,
246 "restart warning set"
247 );
248 Ok(axum::http::StatusCode::NO_CONTENT)
249 }
250
251 /// GET /api/restart-status
252 ///
253 /// Public, unauthenticated. Returns the pending restart timestamp (or null).
254 /// Single atomic load, no DB, no session.
255 pub(in crate::routes::api) async fn restart_status(State(ops): State<Ops>) -> impl IntoResponse {
256 let ts = ops.restart_at.load(Ordering::Relaxed);
257 let restart_at = if ts > 0 { Some(ts) } else { None };
258 Json(serde_json::json!({ "restart_at": restart_at }))
259 }
260