| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | mod handlers; |
| 15 | |
| 16 | use std::sync::Arc; |
| 17 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 18 | |
| 19 | use axum::extract::FromRequestParts; |
| 20 | use axum::http::request::Parts; |
| 21 | use axum::http::{StatusCode, header}; |
| 22 | use axum::response::{IntoResponse, Response}; |
| 23 | use axum::routing::get; |
| 24 | use axum::{Json, Router}; |
| 25 | use config::Config; |
| 26 | use model::User; |
| 27 | use store::Store; |
| 28 | |
| 29 | |
| 30 | #[derive(Clone)] |
| 31 | pub struct ApiState { |
| 32 | |
| 33 | pub store: Store, |
| 34 | |
| 35 | pub config: Arc<Config>, |
| 36 | |
| 37 | pub secret: Arc<String>, |
| 38 | } |
| 39 | |
| 40 | |
| 41 | pub fn router(store: Store, config: Arc<Config>, secret: String) -> Router { |
| 42 | let state = ApiState { |
| 43 | store, |
| 44 | config, |
| 45 | secret: Arc::new(secret), |
| 46 | }; |
| 47 | Router::new() |
| 48 | .route("/version", get(handlers::version)) |
| 49 | .route("/user", get(handlers::current_user)) |
| 50 | .route("/users", get(handlers::list_users)) |
| 51 | .route( |
| 52 | "/repos", |
| 53 | get(handlers::list_repos).post(handlers::create_repo), |
| 54 | ) |
| 55 | .route( |
| 56 | "/repos/{owner}/{*rest}", |
| 57 | get(handlers::repo_get).delete(handlers::repo_delete), |
| 58 | ) |
| 59 | .route("/groups/{owner}", get(handlers::list_groups)) |
| 60 | .route("/search", get(handlers::search)) |
| 61 | .fallback(handlers::not_found) |
| 62 | .with_state(state) |
| 63 | } |
| 64 | |
| 65 | |
| 66 | #[derive(Debug)] |
| 67 | pub enum ApiError { |
| 68 | |
| 69 | Unauthorized(&'static str), |
| 70 | |
| 71 | Forbidden(&'static str), |
| 72 | |
| 73 | NotFound, |
| 74 | |
| 75 | BadRequest(String), |
| 76 | |
| 77 | Conflict(String), |
| 78 | |
| 79 | Internal(String), |
| 80 | } |
| 81 | |
| 82 | impl ApiError { |
| 83 | |
| 84 | pub fn internal(detail: impl std::fmt::Display) -> Self { |
| 85 | Self::Internal(detail.to_string()) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | impl From<store::StoreError> for ApiError { |
| 90 | fn from(err: store::StoreError) -> Self { |
| 91 | match err { |
| 92 | store::StoreError::Conflict { entity, field } => { |
| 93 | Self::Conflict(format!("{entity} already exists with that {field}")) |
| 94 | } |
| 95 | store::StoreError::Name(e) => Self::BadRequest(e.to_string()), |
| 96 | e @ store::StoreError::GroupTooDeep { .. } => Self::BadRequest(e.to_string()), |
| 97 | other => Self::internal(other), |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | impl From<git::GitError> for ApiError { |
| 103 | fn from(err: git::GitError) -> Self { |
| 104 | Self::internal(err) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | impl IntoResponse for ApiError { |
| 109 | fn into_response(self) -> Response { |
| 110 | let (status, code, message) = match self { |
| 111 | ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.to_string()), |
| 112 | ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.to_string()), |
| 113 | ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", "not found".to_string()), |
| 114 | ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m), |
| 115 | ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m), |
| 116 | ApiError::Internal(detail) => { |
| 117 | tracing::error!(error.detail = %detail, "api internal error"); |
| 118 | ( |
| 119 | StatusCode::INTERNAL_SERVER_ERROR, |
| 120 | "internal", |
| 121 | "internal error".to_string(), |
| 122 | ) |
| 123 | } |
| 124 | }; |
| 125 | let body = Json(serde_json::json!({ |
| 126 | "error": { "code": code, "message": message, "details": {} } |
| 127 | })); |
| 128 | let mut response = (status, body).into_response(); |
| 129 | if status == StatusCode::UNAUTHORIZED { |
| 130 | response.headers_mut().insert( |
| 131 | header::WWW_AUTHENTICATE, |
| 132 | header::HeaderValue::from_static("Bearer"), |
| 133 | ); |
| 134 | } |
| 135 | response |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | |
| 140 | pub type ApiResult<T> = Result<T, ApiError>; |
| 141 | |
| 142 | |
| 143 | |
| 144 | |
| 145 | pub struct ApiAuth { |
| 146 | |
| 147 | pub user: User, |
| 148 | |
| 149 | pub scopes: Vec<auth::Scope>, |
| 150 | } |
| 151 | |
| 152 | impl ApiAuth { |
| 153 | |
| 154 | |
| 155 | |
| 156 | |
| 157 | |
| 158 | pub fn require(&self, scope: auth::Scope) -> ApiResult<()> { |
| 159 | if self.scopes.contains(&scope) || self.scopes.contains(&auth::Scope::Admin) { |
| 160 | Ok(()) |
| 161 | } else { |
| 162 | Err(ApiError::Forbidden("missing required scope")) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | |
| 167 | #[must_use] |
| 168 | pub fn viewer(&self) -> auth::Viewer { |
| 169 | auth::Viewer { |
| 170 | id: self.user.id.clone(), |
| 171 | is_admin: self.user.is_admin, |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | impl FromRequestParts<ApiState> for ApiAuth { |
| 177 | type Rejection = ApiError; |
| 178 | |
| 179 | async fn from_request_parts( |
| 180 | parts: &mut Parts, |
| 181 | state: &ApiState, |
| 182 | ) -> Result<Self, Self::Rejection> { |
| 183 | let token = parts |
| 184 | .headers |
| 185 | .get(header::AUTHORIZATION) |
| 186 | .and_then(|v| v.to_str().ok()) |
| 187 | .and_then(|v| v.strip_prefix("Bearer ")) |
| 188 | .ok_or(ApiError::Unauthorized("missing bearer token"))?; |
| 189 | |
| 190 | let claims = auth::verify_jwt(&state.secret, token.trim(), now_secs()) |
| 191 | .map_err(|_| ApiError::Unauthorized("invalid or expired token"))?; |
| 192 | |
| 193 | |
| 194 | let row = state |
| 195 | .store |
| 196 | .token_by_id(&claims.jti) |
| 197 | .await? |
| 198 | .ok_or(ApiError::Unauthorized("token revoked"))?; |
| 199 | if row.revoked_at.is_some() { |
| 200 | return Err(ApiError::Unauthorized("token revoked")); |
| 201 | } |
| 202 | let user = state |
| 203 | .store |
| 204 | .user_by_id(&claims.sub) |
| 205 | .await? |
| 206 | .ok_or(ApiError::Unauthorized("no such user"))?; |
| 207 | if user.disabled_at.is_some() { |
| 208 | return Err(ApiError::Unauthorized("account disabled")); |
| 209 | } |
| 210 | let _ = state.store.touch_token(&claims.jti).await; |
| 211 | |
| 212 | Ok(Self { |
| 213 | user, |
| 214 | scopes: auth::parse_scopes(&claims.scopes).unwrap_or_default(), |
| 215 | }) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | |
| 220 | pub(crate) fn now_secs() -> i64 { |
| 221 | SystemTime::now() |
| 222 | .duration_since(UNIX_EPOCH) |
| 223 | .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) |
| 224 | } |
| 225 | |
| 226 | |
| 227 | |
| 228 | pub(crate) fn rfc3339(ms: i64) -> serde_json::Value { |
| 229 | let nanos = i128::from(ms) * 1_000_000; |
| 230 | match time::OffsetDateTime::from_unix_timestamp_nanos(nanos) { |
| 231 | Ok(dt) => dt |
| 232 | .format(&time::format_description::well_known::Rfc3339) |
| 233 | .map_or(serde_json::Value::Null, serde_json::Value::String), |
| 234 | Err(_) => serde_json::Value::Null, |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | |
| 239 | pub(crate) fn rfc3339_opt(ms: Option<i64>) -> serde_json::Value { |
| 240 | ms.map_or(serde_json::Value::Null, rfc3339) |
| 241 | } |
| 242 | |
| 243 | |
| 244 | pub(crate) async fn git_read<T, F>(state: &ApiState, repo_id: &str, f: F) -> ApiResult<T> |
| 245 | where |
| 246 | F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static, |
| 247 | T: Send + 'static, |
| 248 | { |
| 249 | let repo_dir = state.config.storage.repo_dir.clone(); |
| 250 | let repo_id = repo_id.to_string(); |
| 251 | tokio::task::spawn_blocking(move || { |
| 252 | let path = git::repo_path(&repo_dir, &repo_id)?; |
| 253 | let repo = git::Repo::open_path(&path)?; |
| 254 | f(&repo) |
| 255 | }) |
| 256 | .await |
| 257 | .map_err(ApiError::internal)? |
| 258 | .map_err(ApiError::from) |
| 259 | } |