// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The JSON API served under `/api/v1`. //! //! Bearer-JWT authenticated (`auth`), scoped, and — like the web UI — it renders //! `404` (never `403`) for a private resource the token cannot reach, so existence //! never leaks. Errors share one shape: //! `{"error": {"code": …, "message": …, "details": {}}}`. Timestamps are RFC 3339 //! strings on the wire even though they are epoch-ms at rest. The router is built //! by [`router`] and nested at `/api/v1` by the web server. mod handlers; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; use config::Config; use model::User; use store::Store; /// Shared state for the API router. #[derive(Clone)] pub struct ApiState { /// Database handle. pub store: Store, /// Effective configuration. pub config: Arc, /// The HS256 signing secret used to verify tokens. pub secret: Arc, } /// Build the API router, fully stated. Nest it at `/api/v1`. pub fn router(store: Store, config: Arc, secret: String) -> Router { let state = ApiState { store, config, secret: Arc::new(secret), }; Router::new() .route("/version", get(handlers::version)) .route("/user", get(handlers::current_user)) .route("/users", get(handlers::list_users)) .route( "/repos", get(handlers::list_repos).post(handlers::create_repo), ) .route( "/repos/{owner}/{*rest}", get(handlers::repo_get).delete(handlers::repo_delete), ) .route("/groups/{owner}", get(handlers::list_groups)) .route("/search", get(handlers::search)) .fallback(handlers::not_found) .with_state(state) } /// An API error, rendered as the shared JSON error shape. #[derive(Debug)] pub enum ApiError { /// 401 — missing or invalid bearer token. Unauthorized(&'static str), /// 403 — the token lacks the required scope. Forbidden(&'static str), /// 404 — missing, or a private resource the token cannot reach. NotFound, /// 400 — a malformed request. BadRequest(String), /// 409 — a uniqueness conflict. Conflict(String), /// 500 — an internal failure (detail logged, not shown). Internal(String), } impl ApiError { /// Build an internal error, logging the detail. pub fn internal(detail: impl std::fmt::Display) -> Self { Self::Internal(detail.to_string()) } } impl From for ApiError { fn from(err: store::StoreError) -> Self { match err { store::StoreError::Conflict { entity, field } => { Self::Conflict(format!("{entity} already exists with that {field}")) } store::StoreError::Name(e) => Self::BadRequest(e.to_string()), e @ store::StoreError::GroupTooDeep { .. } => Self::BadRequest(e.to_string()), other => Self::internal(other), } } } impl From for ApiError { fn from(err: git::GitError) -> Self { Self::internal(err) } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, code, message) = match self { ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.to_string()), ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.to_string()), ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", "not found".to_string()), ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m), ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m), ApiError::Internal(detail) => { tracing::error!(error.detail = %detail, "api internal error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal error".to_string(), ) } }; let body = Json(serde_json::json!({ "error": { "code": code, "message": message, "details": {} } })); let mut response = (status, body).into_response(); if status == StatusCode::UNAUTHORIZED { response.headers_mut().insert( header::WWW_AUTHENTICATE, header::HeaderValue::from_static("Bearer"), ); } response } } /// A result alias for API handlers. pub type ApiResult = Result; /// The authenticated token identity, extracted from the `Authorization: Bearer` /// header. Verifies the signature and expiry, that the `jti` row exists and is not /// revoked, and that the user is enabled; bumps `last_used_at`. pub struct ApiAuth { /// The token's user. pub user: User, /// The token's scopes. pub scopes: Vec, } impl ApiAuth { /// Require a scope; `admin` grants everything. /// /// # Errors /// /// Returns [`ApiError::Forbidden`] if the scope is absent. pub fn require(&self, scope: auth::Scope) -> ApiResult<()> { if self.scopes.contains(&scope) || self.scopes.contains(&auth::Scope::Admin) { Ok(()) } else { Err(ApiError::Forbidden("missing required scope")) } } /// A [`auth::Viewer`] for permission checks. #[must_use] pub fn viewer(&self) -> auth::Viewer { auth::Viewer { id: self.user.id.clone(), is_admin: self.user.is_admin, } } } impl FromRequestParts for ApiAuth { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &ApiState, ) -> Result { let token = parts .headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(ApiError::Unauthorized("missing bearer token"))?; let claims = auth::verify_jwt(&state.secret, token.trim(), now_secs()) .map_err(|_| ApiError::Unauthorized("invalid or expired token"))?; // Revocation is real only because we check the jti row here. let row = state .store .token_by_id(&claims.jti) .await? .ok_or(ApiError::Unauthorized("token revoked"))?; if row.revoked_at.is_some() { return Err(ApiError::Unauthorized("token revoked")); } let user = state .store .user_by_id(&claims.sub) .await? .ok_or(ApiError::Unauthorized("no such user"))?; if user.disabled_at.is_some() { return Err(ApiError::Unauthorized("account disabled")); } let _ = state.store.touch_token(&claims.jti).await; Ok(Self { user, scopes: auth::parse_scopes(&claims.scopes).unwrap_or_default(), }) } } /// The current time in Unix seconds. pub(crate) fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) } /// Format an epoch-millisecond timestamp as an RFC 3339 string, or `null` on /// overflow. pub(crate) fn rfc3339(ms: i64) -> serde_json::Value { let nanos = i128::from(ms) * 1_000_000; match time::OffsetDateTime::from_unix_timestamp_nanos(nanos) { Ok(dt) => dt .format(&time::format_description::well_known::Rfc3339) .map_or(serde_json::Value::Null, serde_json::Value::String), Err(_) => serde_json::Value::Null, } } /// Format an optional timestamp. pub(crate) fn rfc3339_opt(ms: Option) -> serde_json::Value { ms.map_or(serde_json::Value::Null, rfc3339) } /// Run a blocking git read for a repo on the blocking pool. pub(crate) async fn git_read(state: &ApiState, repo_id: &str, f: F) -> ApiResult where F: FnOnce(&git::Repo) -> Result + Send + 'static, T: Send + 'static, { let repo_dir = state.config.storage.repo_dir.clone(); let repo_id = repo_id.to_string(); tokio::task::spawn_blocking(move || { let path = git::repo_path(&repo_dir, &repo_id)?; let repo = git::Repo::open_path(&path)?; f(&repo) }) .await .map_err(ApiError::internal)? .map_err(ApiError::from) }