gitdataai/lib/git/http/lfs_routes.rs
2026-05-30 01:38:40 +08:00

551 lines
17 KiB
Rust

use std::path::PathBuf;
use actix_web::{Error, HttpRequest, HttpResponse, web};
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordVerifier},
};
use model::{
repos::RepoModel,
users::{user::UserModel, user_token::UserTokenModel},
};
use crate::{
errors::GitError,
http::{
HttpAppState,
auth::{authorize_repo_access, verify_access_token},
handler::is_valid_lfs_oid,
lfs::{BatchRequest, CreateLockRequest, LfsHandler},
utils::{extract_basic_credentials, get_repo_model},
},
ssh::authz::SshAuthService,
sync::push_queue::{
PushQueueEvent, PushQueueLease, PushQueueWaitError,
wait_for_push_queue_slot,
},
};
fn base_url(req: &HttpRequest) -> String {
let conn_info = req.connection_info();
format!("{}://{}", conn_info.scheme(), conn_info.host())
}
fn bearer_token(req: &HttpRequest) -> Result<String, Error> {
let auth_header = req
.headers()
.get("authorization")
.ok_or_else(|| {
actix_web::error::ErrorUnauthorized("Missing authorization header")
})?
.to_str()
.map_err(|_| {
actix_web::error::ErrorUnauthorized("Invalid authorization header")
})?;
if let Some(token) = auth_header.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(actix_web::error::ErrorUnauthorized(
"Invalid authorization format",
))
}
}
async fn user_uid(
req: &HttpRequest,
db: &db::database::AppDatabase,
) -> Result<uuid::Uuid, Error> {
if let Ok((username, access_key)) = extract_basic_credentials(req) {
return verify_access_token(db, &username, &access_key)
.await
.map(|user| user.id);
}
let token = bearer_token(req)?;
find_user_by_bearer_token(&token, db).await
}
pub async fn store_lfs_token(
cache: &cache::AppCache,
token: &str,
repo_id: uuid::Uuid,
user_uid: uuid::Uuid,
operation: &str,
) {
if let Some(mut conn) = cache.conn() {
use redis::AsyncCommands;
let value = format!("{}:{}:{}", repo_id, user_uid, operation);
let _: () = conn
.set_ex(format!("lfs:token:{}", token), value, 3600_u64)
.await
.map_err(
|e| tracing::warn!(error = %e, "failed to store lfs token"),
)
.unwrap_or(());
}
}
async fn validate_lfs_token(
token: &str,
cache: &cache::AppCache,
db: &db::database::AppDatabase,
expected_repo_id: uuid::Uuid,
expected_operation: &str,
) -> Result<uuid::Uuid, Error> {
if let Some(mut conn) = cache.conn() {
use redis::AsyncCommands;
let stored: Option<String> = conn
.get::<String, String>(format!("lfs:token:{}", token))
.await
.ok();
if let Some(value) = stored {
let parts: Vec<&str> = value.split(':').collect();
if parts.len() == 3 {
let repo_id =
uuid::Uuid::parse_str(parts[0]).map_err(|_| {
actix_web::error::ErrorUnauthorized(
"Invalid batch token",
)
})?;
let user_uid =
uuid::Uuid::parse_str(parts[1]).map_err(|_| {
actix_web::error::ErrorUnauthorized(
"Invalid batch token",
)
})?;
let operation = parts[2];
if repo_id != expected_repo_id {
return Err(actix_web::error::ErrorUnauthorized(
"Token not valid for this repo",
));
}
if operation != expected_operation {
return Err(actix_web::error::ErrorUnauthorized(
"Token not valid for this operation",
));
}
let _: Result<(), redis::RedisError> =
conn.del(format!("lfs:token:{}", token)).await;
return Ok(user_uid);
}
}
}
find_user_by_bearer_token(token, db).await
}
async fn find_user_by_bearer_token(
token: &str,
db: &db::database::AppDatabase,
) -> Result<uuid::Uuid, Error> {
let tokens: Vec<UserTokenModel> = sqlx::query_as::<_, UserTokenModel>(
"SELECT id, \"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at \
FROM user_token \
WHERE is_revoked = false",
)
.fetch_all(db.reader())
.await
.map_err(|_| actix_web::error::ErrorUnauthorized("Authentication failed"))?;
for token_model in tokens {
if token_model
.expires_at
.map(|expires_at| expires_at < chrono::Utc::now())
.unwrap_or(false)
{
continue;
}
let Ok(hash) = PasswordHash::new(&token_model.token_hash) else {
tracing::warn!(
token_id = token_model.id,
"invalid stored bearer token hash"
);
continue;
};
if Argon2::default()
.verify_password(token.as_bytes(), &hash)
.is_ok()
{
return Ok(token_model.user);
}
}
Err(actix_web::error::ErrorUnauthorized("Invalid token"))
}
async fn authorize_user_repo_access(
db: &db::database::AppDatabase,
user_uid: uuid::Uuid,
repo: &RepoModel,
is_write: bool,
) -> Result<(), Error> {
let user = sqlx::query_as::<_, UserModel>(
"SELECT id, username, display_name, avatar_url, website_url, allow_use, can_search, \
last_sign_in_at, created_at, updated_at \
FROM \"user\" \
WHERE id = $1",
)
.bind(user_uid)
.fetch_optional(db.reader())
.await
.map_err(|_| actix_web::error::ErrorUnauthorized("Authentication failed"))?
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid token user"))?;
let authz = SshAuthService::new(db.clone());
if authz.check_repo_permission(&user, repo, is_write).await {
Ok(())
} else {
Err(actix_web::error::ErrorForbidden(
"No permission for repository",
))
}
}
async fn acquire_lfs_write_queue(
state: &HttpAppState,
repo: &RepoModel,
operation: &'static str,
) -> Result<PushQueueLease, Error> {
match wait_for_push_queue_slot(
state.sync.clone(),
repo.id,
|event, request_id| {
let request_id = request_id.to_string();
match event {
PushQueueEvent::Waiting(position) => {
tracing::info!(
repo = %repo.name,
repo_id = %repo.id,
request_id = %request_id,
operation = operation,
position = position.position,
total = position.total,
"lfs_write_queue_waiting"
);
}
PushQueueEvent::Acquired => {
tracing::info!(
repo = %repo.name,
repo_id = %repo.id,
request_id = %request_id,
operation = operation,
"lfs_write_queue_acquired"
);
}
}
},
)
.await
{
Ok(lease) => Ok(lease),
Err(PushQueueWaitError::Join(e)) => {
tracing::error!(
error = %e,
repo = %repo.name,
repo_id = %repo.id,
operation = operation,
"lfs_write_queue_join_failed"
);
Err(actix_web::error::ErrorServiceUnavailable(
"LFS write queue is temporarily unavailable. Please retry later.",
))
}
Err(PushQueueWaitError::Lock(e)) => {
tracing::error!(
error = %e,
repo = %repo.name,
repo_id = %repo.id,
operation = operation,
"lfs_write_queue_lock_failed"
);
Err(actix_web::error::ErrorServiceUnavailable(
"LFS write queue lock failed. Please retry later.",
))
}
Err(PushQueueWaitError::Timeout) => {
tracing::warn!(
repo = %repo.name,
repo_id = %repo.id,
operation = operation,
"lfs_write_queue_timeout"
);
Err(actix_web::error::ErrorServiceUnavailable(
"LFS write queue timed out. Please retry in a moment.",
))
}
}
}
pub async fn lfs_batch(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<BatchRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let batch_req = body.into_inner();
let is_write = batch_req.operation == "upload";
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
if repo.visibility != "public" || is_write {
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, is_write).await?;
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo,
namespace,
state.db.clone(),
);
let response = handler
.batch_with_auth(batch_req, &base_url(&req), uid, &state.cache)
.await
.map_err(|_| {
actix_web::error::ErrorInternalServerError("LFS batch failed")
})?;
Ok(HttpResponse::Ok()
.content_type("application/vnd.git-lfs+json")
.json(response))
} else {
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo,
namespace,
state.db.clone(),
);
let response = handler
.batch(batch_req, &base_url(&req))
.await
.map_err(|_| {
actix_web::error::ErrorInternalServerError("LFS batch failed")
})?;
Ok(HttpResponse::Ok()
.content_type("application/vnd.git-lfs+json")
.json(response))
}
}
pub async fn lfs_upload(
req: HttpRequest,
path: web::Path<(String, String, String)>,
payload: web::Payload,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let token = bearer_token(&req)?;
let uid =
validate_lfs_token(&token, &state.cache, &state.db, repo.id, "upload")
.await?;
authorize_user_repo_access(&state.db, uid, &repo, true).await?;
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo.clone(),
namespace,
state.db.clone(),
);
let mut queue_lease =
acquire_lfs_write_queue(&state, &handler.model, "upload").await?;
let result = match handler.upload_object(&oid, payload).await {
Ok(response) => Ok(response),
Err(GitError::InvalidOid(_)) => {
Err(actix_web::error::ErrorBadRequest("Invalid OID"))
}
Err(GitError::AuthFailed(_)) => {
Err(actix_web::error::ErrorUnauthorized("Unauthorized"))
}
Err(_e) => {
Err(actix_web::error::ErrorInternalServerError("Upload failed"))
}
};
queue_lease.release().await;
result
}
pub async fn lfs_download(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
if repo.visibility != "public" {
let token = bearer_token(&req)?;
let uid = validate_lfs_token(
&token,
&state.cache,
&state.db,
repo.id,
"download",
)
.await?;
authorize_user_repo_access(&state.db, uid, &repo, false).await?;
}
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo,
namespace,
state.db.clone(),
);
match handler.download_object(&oid).await {
Ok(response) => Ok(response),
Err(GitError::NotFound(_)) => {
Err(actix_web::error::ErrorNotFound("Object not found"))
}
Err(GitError::AuthFailed(_)) => {
Err(actix_web::error::ErrorUnauthorized("Unauthorized"))
}
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Download failed",
)),
}
}
pub async fn lfs_lock_create(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<CreateLockRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, true).await?;
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo.clone(),
namespace,
state.db.clone(),
);
let mut queue_lease =
acquire_lfs_write_queue(&state, &handler.model, "lock_create").await?;
let result = match handler.lock_object(&body.oid, uid).await {
Ok(lock) => Ok(HttpResponse::Created().json(lock)),
Err(GitError::Locked(msg)) => Ok(HttpResponse::Conflict().body(msg)),
Err(_e) => {
Err(actix_web::error::ErrorInternalServerError("Lock failed"))
}
};
queue_lease.release().await;
result
}
pub async fn lfs_lock_list(
req: HttpRequest,
path: web::Path<(String, String)>,
query: web::Query<std::collections::HashMap<String, String>>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
if repo.visibility != "public" {
let uid = user_uid(&req, &state.db).await?;
authorize_user_repo_access(&state.db, uid, &repo, false).await?;
}
let maybe_oid = query.get("oid").map(|s| s.as_str());
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo,
namespace,
state.db.clone(),
);
match handler.list_locks(maybe_oid).await {
Ok(list) => Ok(HttpResponse::Ok().json(list)),
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock list failed",
)),
}
}
pub async fn lfs_lock_get(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_id) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
if repo.visibility != "public" {
let uid = user_uid(&req, &state.db).await?;
authorize_user_repo_access(&state.db, uid, &repo, false).await?;
}
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo,
namespace,
state.db.clone(),
);
match handler.get_lock(&lock_id).await {
Ok(lock) => Ok(HttpResponse::Ok().json(lock)),
Err(GitError::NotFound(_)) => {
Err(actix_web::error::ErrorNotFound("Lock not found"))
}
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock get failed",
)),
}
}
pub async fn lfs_lock_delete(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_id) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, true).await?;
let handler = LfsHandler::new(
PathBuf::from(&repo.storage_path),
repo.clone(),
namespace,
state.db.clone(),
);
let mut queue_lease =
acquire_lfs_write_queue(&state, &handler.model, "lock_delete").await?;
let result = match handler.unlock_object(&lock_id, uid).await {
Ok(()) => Ok(HttpResponse::NoContent().finish()),
Err(GitError::PermissionDenied(_)) => {
Err(actix_web::error::ErrorForbidden("Not allowed"))
}
Err(GitError::NotFound(_)) => {
Err(actix_web::error::ErrorNotFound("Lock not found"))
}
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock delete failed",
)),
};
queue_lease.release().await;
result
}