| 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 | } |