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 track::CounterVec; 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 { 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 { 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 { if let Some(mut conn) = cache.conn() { use redis::AsyncCommands; let stored: Option = conn .get::(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 { let tokens: Vec = 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 { 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, state: web::Data, ) -> Result { 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") })?; record_lfs_op(&state, "batch", "success"); 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") })?; record_lfs_op(&state, "batch", "success"); Ok(HttpResponse::Ok() .content_type("application/vnd.git-lfs+json") .json(response)) } } fn record_lfs_op(state: &HttpAppState, op: &str, outcome: &str) { if let Some(reg) = &state.metrics { lfs_counter(reg).with_label_values(&[op, outcome]).inc(); } } fn lfs_counter(registry: &track::MetricsRegistry) -> CounterVec { registry .register_counter_vec( "git_lfs_operations_total", "Total Git LFS operations", &["operation", "outcome"], ) .expect("failed to register git_lfs_operations_total") } pub async fn lfs_upload( req: HttpRequest, path: web::Path<(String, String, String)>, payload: web::Payload, state: web::Data, ) -> Result { 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) => { record_lfs_op(&state, "upload", "success"); 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, ) -> Result { 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) => { record_lfs_op(&state, "download", "success"); 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, state: web::Data, ) -> Result { 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) => { record_lfs_op(&state, "lock_create", "success"); Ok(HttpResponse::Created().json(lock)) } Err(GitError::Locked(msg)) => { record_lfs_op(&state, "lock_create", "already_locked"); 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>, state: web::Data, ) -> Result { 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) => { record_lfs_op(&state, "lock_list", "success"); 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, ) -> Result { 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) => { record_lfs_op(&state, "lock_get", "success"); 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, ) -> Result { 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(()) => { record_lfs_op(&state, "lock_delete", "success"); 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 }