Skip to content
Clone
c3eb022
403 lines 11.9 KB Rust
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<GitService> {
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<GitService>,
Path((space, repo_slug)): Path<(String, String)>,
) -> Result<Json<Discovery>, 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug)): Path<(String, String)>,
Json(req): Json<LfsBatchRequest>,
) -> Result<Json<LfsBatchResponse>, 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug, oid)): Path<(String, String, String)>,
headers: HeaderMap,
body: Body,
) -> Result<StatusCode, 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 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug, oid)): Path<(String, String, String)>,
) -> Result<Response, 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 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug)): Path<(String, String)>,
Json(req): Json<crate::lfs::types::LfsVerifyRequest>,
) -> Result<StatusCode, LfsHttpError> {
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<GitService>,
auth: BasicAuth,
Path((space, repo_slug)): Path<(String, String)>,
Query(q): Query<ListLocksQuery>,
) -> Result<Json<LfsLockListResponse>, 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug)): Path<(String, String)>,
Json(req): Json<LfsLockCreateRequest>,
) -> Result<(StatusCode, Json<LockResponse>), 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug)): Path<(String, String)>,
Json(req): Json<LfsVerifyLocksRequest>,
) -> Result<Json<LfsVerifyLocksResponse>, 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<GitService>,
auth: BasicAuth,
Path((space, repo_slug, lock_id)): Path<(String, String, String)>,
Query(q): Query<UnlockQuery>,
) -> Result<Json<UnlockResponse>, 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<String>,
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<i64, &'static str> {
let raw = headers
.get(header::CONTENT_LENGTH)
.ok_or("missing Content-Length header")?;
raw.to_str()
.ok()
.and_then(|s| s.parse::<i64>().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)]
pub struct LfsHttpError {
response: Response,
}
impl LfsHttpError {
pub fn unauthorized() -> Self {
Self {
response: challenge(),
}
}
fn bad_request(message: &str) -> Self {
Self {
response: status_json(StatusCode::BAD_REQUEST, message),
}
}
}
impl IntoResponse for LfsHttpError {
fn into_response(self) -> Response {
self.response
}
}
impl From<GitError> 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: (
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: status_json(status, &message),
}
}
}
}