217 lines · 9473 bytes
1 use axum::{
2 extract::{Path, State},
3 http::{HeaderMap, StatusCode},
4 response::IntoResponse,
5 Json,
6 };
7 use serde::Deserialize;
8 use serde_json::json;
9 use sqlx::SqlitePool;
10 use std::sync::Arc;
11
12 use crate::{auth, config::Config, repos, validate};
13
14 #[derive(Clone)]
15 pub struct ApiState {
16 pub cfg: Arc<Config>,
17 pub pool: SqlitePool,
18 }
19
20 fn bearer_token(headers: &HeaderMap) -> Option<&str> {
21 let v = headers.get("authorization")?.to_str().ok()?;
22 v.strip_prefix("Bearer ")
23 }
24
25 #[derive(serde::Serialize)]
26 struct ErrObj { error: ErrInner }
27 #[derive(serde::Serialize)]
28 struct ErrInner { code: String, message: String }
29
30 fn json_err(code: &str, message: &str, status: StatusCode) -> (StatusCode, Json<ErrObj>) {
31 (status, Json(ErrObj { error: ErrInner { code: code.into(), message: message.into() } }))
32 }
33
34 pub async fn signup(State(st): State<ApiState>, Json(req): Json<auth::SignupReq>) -> impl IntoResponse {
35 let u = req.username.trim();
36 if !validate::validate_username(u) {
37 return json_err("INVALID_INPUT", "Invalid username", StatusCode::BAD_REQUEST).into_response();
38 }
39 if !req.public_key.starts_with("ssh-ed25519 ") {
40 return json_err("INVALID_INPUT", "Public key must be ssh-ed25519", StatusCode::BAD_REQUEST).into_response();
41 }
42 // Validate the key actually parses
43 if ssh_key::PublicKey::from_openssh(&req.public_key).is_err() {
44 return json_err("INVALID_INPUT", "Invalid public key format", StatusCode::BAD_REQUEST).into_response();
45 }
46 match auth::create_user(&st.pool, u, &req.public_key, &req.invite_code).await {
47 Ok(user) => (StatusCode::CREATED, Json(json!({"user": user}))).into_response(),
48 Err(e) if e == "invalid_invite" || e == "invite_used" => {
49 json_err("INVALID_INVITE", "Invalid or used invite code", StatusCode::BAD_REQUEST).into_response()
50 }
51 Err(e) if e == "username_taken" => {
52 json_err("USERNAME_TAKEN", "Username already exists", StatusCode::CONFLICT).into_response()
53 }
54 Err(_) => json_err("ERROR", "Signup failed", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
55 }
56 }
57
58 pub async fn login_challenge(State(st): State<ApiState>, Json(req): Json<auth::ChallengeReq>) -> impl IntoResponse {
59 let u = req.username.trim();
60 match auth::create_challenge(&st.pool, u).await {
61 Ok(nonce) => (StatusCode::OK, Json(json!({"nonce": nonce}))).into_response(),
62 Err(e) if e == "user_not_found" => {
63 json_err("USER_NOT_FOUND", "User not found", StatusCode::NOT_FOUND).into_response()
64 }
65 Err(_) => json_err("ERROR", "Challenge failed", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
66 }
67 }
68
69 pub async fn login_verify(State(st): State<ApiState>, Json(req): Json<auth::VerifyReq>) -> impl IntoResponse {
70 let u = req.username.trim();
71 match auth::verify_challenge_and_login(&st.pool, u, &req.nonce, &req.signature).await {
72 Ok((token, user)) => (StatusCode::OK, Json(json!({"token": token, "user": user}))).into_response(),
73 Err(e) if e == "invalid_challenge" || e == "challenge_expired" => {
74 json_err("INVALID_CHALLENGE", "Invalid or expired challenge", StatusCode::BAD_REQUEST).into_response()
75 }
76 Err(e) if e == "invalid_signature" => {
77 json_err("INVALID_SIGNATURE", "Signature verification failed", StatusCode::UNAUTHORIZED).into_response()
78 }
79 Err(e) if e == "user_not_found" => {
80 json_err("USER_NOT_FOUND", "User not found", StatusCode::NOT_FOUND).into_response()
81 }
82 Err(_) => json_err("ERROR", "Login failed", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
83 }
84 }
85
86 pub async fn create_invite(State(st): State<ApiState>, headers: HeaderMap) -> impl IntoResponse {
87 let admin_key = match &st.cfg.admin_key {
88 Some(k) => k.clone(),
89 None => return json_err("FORBIDDEN", "Admin endpoint not configured", StatusCode::FORBIDDEN).into_response(),
90 };
91 let token = match bearer_token(&headers) {
92 Some(t) => t,
93 None => return json_err("UNAUTHORIZED", "Missing bearer token", StatusCode::FORBIDDEN).into_response(),
94 };
95 if token != admin_key {
96 return json_err("FORBIDDEN", "Invalid admin key", StatusCode::FORBIDDEN).into_response();
97 }
98
99 match auth::create_invite(&st.pool).await {
100 Ok(code) => (StatusCode::CREATED, Json(json!({"code": code}))).into_response(),
101 Err(_) => json_err("ERROR", "Failed to create invite", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
102 }
103 }
104
105 pub async fn stats(State(st): State<ApiState>) -> impl IntoResponse {
106 let users = sqlx::query_scalar!(r#"SELECT COUNT(*) as "c!: i64" FROM users"#)
107 .fetch_one(&st.pool).await.unwrap_or(0);
108 let repos = sqlx::query_scalar!(r#"SELECT COUNT(*) as "c!: i64" FROM repos"#)
109 .fetch_one(&st.pool).await.unwrap_or(0);
110
111 let repositories = sqlx::query!(
112 r#"SELECT repos.name as "name!: String", users.username as "owner!: String"
113 FROM repos JOIN users ON users.id = repos.owner_id
114 ORDER BY repos.created_at DESC LIMIT 50"#
115 ).fetch_all(&st.pool).await.unwrap_or_default();
116
117 let repos_root = st.cfg.repos_root.clone();
118 let repos_json: Vec<_> = repositories.iter().map(|r| {
119 let hash = repos::head_hash(&repos_root, &r.owner, &r.name);
120 json!({"owner": r.owner, "name": r.name, "last_hash": hash})
121 }).collect();
122
123 (StatusCode::OK, Json(json!({
124 "users": users,
125 "repos": repos,
126 "repositories": repos_json
127 }))).into_response()
128 }
129
130 #[derive(Deserialize)]
131 pub struct CreateRepoReq { pub name: String }
132
133 pub async fn create_repo(State(st): State<ApiState>, headers: HeaderMap, Json(req): Json<CreateRepoReq>) -> impl IntoResponse {
134 let token = match bearer_token(&headers) {
135 Some(t) => t,
136 None => return json_err("UNAUTHORIZED", "Missing bearer token", StatusCode::FORBIDDEN).into_response(),
137 };
138 let (uid, username) = match auth::auth_user_by_token(&st.pool, token).await {
139 Some(x) => x,
140 None => return json_err("UNAUTHORIZED", "Invalid token", StatusCode::FORBIDDEN).into_response(),
141 };
142
143 let name = req.name.trim();
144 match repos::create_repo(
145 &st.pool,
146 &st.cfg.repos_root,
147 &st.cfg.hook_path,
148 st.cfg.max_repos_per_user,
149 uid,
150 &username,
151 name,
152 ).await {
153 Ok(()) => {
154 let http_url = format!("/{}/{}.git", username, name);
155 (StatusCode::CREATED, Json(json!({"repo": {"owner": username, "name": name, "http_url": http_url}}))).into_response()
156 }
157 Err(e) if e == "repo_quota_exceeded" => json_err("REPO_QUOTA", "Repo quota exceeded", StatusCode::TOO_MANY_REQUESTS).into_response(),
158 Err(e) if e == "repo_exists" => json_err("REPO_EXISTS", "Repo already exists", StatusCode::CONFLICT).into_response(),
159 Err(e) if e == "invalid_repo_name" => json_err("INVALID_REPO", "Invalid repo name", StatusCode::BAD_REQUEST).into_response(),
160 Err(_) => json_err("ERROR", "Failed to create repo", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
161 }
162 }
163
164 pub async fn list_repos(State(st): State<ApiState>, Path(username): Path<String>) -> impl IntoResponse {
165 let user = sqlx::query!(r#"SELECT id as "id!: i64", username as "username!: String" FROM users WHERE username = ?"#, username)
166 .fetch_optional(&st.pool)
167 .await;
168
169 let user = match user {
170 Ok(Some(u)) => u,
171 _ => return json_err("NOT_FOUND", "User not found", StatusCode::NOT_FOUND).into_response(),
172 };
173
174 let rows = sqlx::query!(r#"SELECT name as "name!: String" FROM repos WHERE owner_id = ? ORDER BY name"#, user.id)
175 .fetch_all(&st.pool)
176 .await;
177
178 let rows = match rows {
179 Ok(r) => r,
180 Err(_) => return json_err("ERROR", "DB error", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
181 };
182
183 let repos_json: Vec<_> = rows
184 .into_iter()
185 .map(|r| {
186 let http_url = format!("/{}/{}.git", user.username, r.name);
187 let hash = repos::head_hash(&st.cfg.repos_root, &user.username, &r.name);
188 json!({"owner": user.username, "name": r.name, "http_url": http_url, "last_hash": hash})
189 })
190 .collect();
191
192 (StatusCode::OK, Json(json!({"repos": repos_json}))).into_response()
193 }
194
195 pub async fn delete_repo(
196 State(st): State<ApiState>,
197 headers: HeaderMap,
198 Path((owner, repo)): Path<(String, String)>,
199 ) -> impl IntoResponse {
200 let token = match bearer_token(&headers) {
201 Some(t) => t,
202 None => return json_err("UNAUTHORIZED", "Missing bearer token", StatusCode::FORBIDDEN).into_response(),
203 };
204 let (_uid, username) = match auth::auth_user_by_token(&st.pool, token).await {
205 Some(x) => x,
206 None => return json_err("UNAUTHORIZED", "Invalid token", StatusCode::FORBIDDEN).into_response(),
207 };
208 if username != owner {
209 return json_err("FORBIDDEN", "Only owner can delete", StatusCode::FORBIDDEN).into_response();
210 }
211
212 match repos::delete_repo(&st.pool, &st.cfg.repos_root, &owner, &repo).await {
213 Ok(()) => StatusCode::NO_CONTENT.into_response(),
214 Err(e) if e == "not_found" => json_err("NOT_FOUND", "Repo not found", StatusCode::NOT_FOUND).into_response(),
215 Err(_) => json_err("ERROR", "Delete failed", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
216 }
217 }