use axum::Router; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post, put}; use futures::StreamExt as _; use http_body_util::BodyDataStream; use serde::{Deserialize, Serialize}; use tokio_util::io::StreamReader; use uuid::Uuid; use crate::lfs::types::{ LfsBatchRequest, LfsBatchResponse, LfsLockCreateRequest, LfsLockListResponse, LfsOperation, LfsVerifyLocksRequest, LfsVerifyLocksResponse, }; use crate::lfs::{error, object}; use crate::service::GitService; use crate::share::access::{BasicAuth, allow_repo_read, require_repo_admin, require_writer}; use crate::share::errors::GitError; const DOCUMENTATION_URL: &str = "https://github.com/git-lfs/git-lfs/blob/main/docs/api/errors.md"; pub fn router(service: GitService) -> Router { Router::new() .route("/info/lfs", get(discovery)) .route("/info/lfs/objects/batch", post(batch)) .route("/info/lfs/objects/verify", post(verify_object)) .route( "/info/lfs/objects/{oid}", put(upload_object).get(download_object), ) .route("/info/lfs/locks", get(list_locks).post(create_lock)) .route("/info/lfs/locks/verify", post(verify_locks)) .route("/info/lfs/locks/{lock_id}/unlock", post(unlock_lock)) .with_state(service) } pub async fn discovery( State(svc): State, Path((space, repo_slug)): Path<(String, String)>, ) -> Result, LfsHttpError> { let repo = skinny_repo_name(&repo_slug); let base = svc .state .config .web .web_base_url .trim_end_matches('/') .to_string(); let href = if base.is_empty() { format!("/{space}/{repo}.git/info/lfs") } else { format!("{base}/{space}/{repo}.git/info/lfs") }; Ok(Json(Discovery { href })) } #[derive(Debug, Serialize)] pub struct Discovery { pub href: String, } pub async fn batch( State(svc): State, auth: BasicAuth, Path((space, repo_slug)): Path<(String, String)>, Json(req): Json, ) -> Result, LfsHttpError> { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; let needs_write = matches!(req.operation, LfsOperation::Upload | LfsOperation::Verify); if needs_write { require_writer(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; } else { allow_repo_read(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; } let resp = object::batch(&svc, space, repo.to_string(), req) .await .map_err(LfsHttpError::from)?; Ok(Json(resp)) } pub async fn upload_object( State(svc): State, auth: BasicAuth, Path((space, repo_slug, oid)): Path<(String, String, String)>, headers: HeaderMap, body: Body, ) -> Result { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; let uid = require_writer(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; let size = content_length(&headers).map_err(LfsHttpError::bad_request)?; let content_type = headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(str::to_string); let stream = BodyDataStream::new(body).map(|chunk| match chunk { Ok(bytes) => Ok(bytes), Err(e) => Err(std::io::Error::other(e.to_string())), }); let reader = StreamReader::new(stream); object::upload_stream( &svc, space, repo.to_string(), uid, oid.clone(), size, content_type, reader, ) .await .map_err(LfsHttpError::from)?; Ok(StatusCode::OK) } pub async fn download_object( State(svc): State, auth: BasicAuth, Path((space, repo_slug, oid)): Path<(String, String, String)>, ) -> Result { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; allow_repo_read(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; let oid = crate::lfs::canonical_oid(&oid)?; let key = format!("lfs/{}/{}", ctx.model.id.simple(), oid); let storage = &svc.state.storage; let stream = storage .client() .get_stream(&key) .await .map_err(|e| LfsHttpError::from(GitError::Internal(format!("lfs get: {e}"))))?; let body = Body::from_stream( stream.map(|chunk| chunk.map_err(|e| -> axum::Error { axum::Error::new(e.to_string()) })), ); Ok((StatusCode::OK, body).into_response()) } pub async fn verify_object( State(svc): State, auth: BasicAuth, Path((space, repo_slug)): Path<(String, String)>, Json(req): Json, ) -> Result { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; require_writer(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; object::verify(&svc, space, repo.to_string(), req) .await .map_err(LfsHttpError::from)?; Ok(StatusCode::OK) } pub async fn list_locks( State(svc): State, auth: BasicAuth, Path((space, repo_slug)): Path<(String, String)>, Query(q): Query, ) -> Result, LfsHttpError> { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; allow_repo_read(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; let resp = crate::lfs::list_locks(&svc, space, repo.to_string(), q.cursor, q.limit) .await .map_err(LfsHttpError::from)?; Ok(Json(resp)) } pub async fn create_lock( State(svc): State, auth: BasicAuth, Path((space, repo_slug)): Path<(String, String)>, Json(req): Json, ) -> Result<(StatusCode, Json), LfsHttpError> { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; let uid = require_writer(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)?; let lock = crate::lfs::create_lock(&svc, space, repo.to_string(), uid, req.path, None) .await .map_err(LfsHttpError::from)?; Ok((StatusCode::CREATED, Json(LockResponse { lock }))) } #[derive(Debug, Serialize)] pub struct LockResponse { lock: crate::lfs::types::LfsLock, } pub async fn verify_locks( State(svc): State, auth: BasicAuth, Path((space, repo_slug)): Path<(String, String)>, Json(req): Json, ) -> Result, LfsHttpError> { let ctx = svc .context(space.clone(), skinny_repo_name(&repo_slug).to_string()) .await .map_err(LfsHttpError::from)?; let uid = match allow_repo_read(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)? { Some(uid) => uid, None => { return Err(LfsHttpError::unauthorized()); } }; let resp = crate::lfs::verify_locks( &svc, space, skinny_repo_name(&repo_slug).to_string(), uid, req.limit.unwrap_or(100) as u64, ) .await .map_err(LfsHttpError::from)?; Ok(Json(resp)) } pub async fn unlock_lock( State(svc): State, auth: BasicAuth, Path((space, repo_slug, lock_id)): Path<(String, String, String)>, Query(q): Query, ) -> Result, LfsHttpError> { let repo = skinny_repo_name(&repo_slug); let ctx = svc .context(space.clone(), repo.to_string()) .await .map_err(LfsHttpError::from)?; // `force=true` (per LFS spec) is reserved for repo admins — must be the // space owner or an Admin collaborator, not an arbitrary Write token. let uid = if q.force { require_repo_admin(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)? } else { require_writer(&svc, &auth, &ctx) .await .map_err(LfsHttpError::from)? }; crate::lfs::unlock_by_id(&svc, space, repo.to_string(), uid, lock_id, q.force) .await .map_err(LfsHttpError::from)?; Ok(Json(UnlockResponse { message: "unlock successful".to_string(), })) } #[derive(Debug, Serialize)] pub struct UnlockResponse { pub message: String, } #[derive(Debug, Deserialize)] pub struct ListLocksQuery { pub cursor: Option, pub limit: u64, } #[derive(Debug, Deserialize)] pub struct UnlockQuery { #[serde(default)] pub force: bool, } fn skinny_repo_name(slug: &str) -> &str { slug.strip_suffix(".git").unwrap_or(slug) } fn content_length(headers: &HeaderMap) -> Result { let raw = headers .get(header::CONTENT_LENGTH) .ok_or("missing Content-Length header")?; raw.to_str() .ok() .and_then(|s| s.parse::().ok()) .ok_or("invalid Content-Length header") } #[derive(Debug, Serialize)] struct LfsErrorBody { message: String, request_id: String, documentation_url: &'static str, } fn status_json(status: StatusCode, message: &str) -> Response { ( status, Json(LfsErrorBody { message: message.to_string(), request_id: Uuid::now_v7().simple().to_string(), documentation_url: DOCUMENTATION_URL, }), ) .into_response() } fn challenge() -> Response { ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")], Json(LfsErrorBody { message: "authentication required".to_string(), request_id: Uuid::now_v7().simple().to_string(), documentation_url: DOCUMENTATION_URL, }), ) .into_response() } #[derive(Debug)] /// Boxed: `Response` is 128 bytes, which would otherwise ride along in /// every `Result` these LFS handlers return. pub struct LfsHttpError { response: Box, } impl LfsHttpError { pub fn unauthorized() -> Self { Self { response: Box::new(challenge()), } } fn bad_request(message: &str) -> Self { Self { response: Box::new(status_json(StatusCode::BAD_REQUEST, message)), } } } impl IntoResponse for LfsHttpError { fn into_response(self) -> Response { *self.response } } impl From for LfsHttpError { fn from(err: GitError) -> Self { let status = StatusCode::from_u16(error::http_status(&err)) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let message = match &err { GitError::Unauthorized => "authentication required".to_string(), GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => { tracing::error!(error = %err, "lfs internal error"); "internal server error".to_string() } _ => err.to_string(), }; if status == StatusCode::UNAUTHORIZED { Self { response: Box::new( ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")], Json(LfsErrorBody { message, request_id: Uuid::now_v7().simple().to_string(), documentation_url: DOCUMENTATION_URL, }), ) .into_response(), ), } } else { Self { response: Box::new(status_json(status, &message)), } } } }