use std::{collections::HashMap, path::PathBuf}; use actix_web::{HttpResponse, web}; use cache::AppCache; use db::database::AppDatabase; use model::repos::{ RepoModel, repo_lfs_lock::RepoLfsLockModel, repo_lfs_object::RepoLfsObjectModel, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{errors::GitError, http::handler::is_valid_lfs_oid}; const LFS_AUTH_TOKEN_EXPIRY: u64 = 3600; const LFS_MAX_OBJECT_SIZE: i64 = 50 * 1024 * 1024 * 1024; #[derive(Deserialize, Serialize)] pub struct BatchRequest { pub operation: String, pub objects: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub transfers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub r#ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hash_algo: Option, } #[derive(Deserialize, Serialize)] pub struct LfsRef { pub name: String, } #[derive(Deserialize, Serialize, Clone)] pub struct LfsObjectReq { pub oid: String, pub size: i64, } #[derive(Serialize)] pub struct BatchResponse { pub transfer: String, pub objects: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub hash_algo: Option, } #[derive(Serialize)] pub struct LfsObjectResponse { pub oid: String, pub size: i64, #[serde(skip_serializing_if = "Option::is_none")] pub authenticated: Option, #[serde(skip_serializing_if = "Option::is_none")] pub actions: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Serialize)] pub struct LfsAction { pub href: String, #[serde(skip_serializing_if = "Option::is_none")] pub header: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub expires_in: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, } #[derive(Serialize)] pub struct LfsError { pub code: i32, pub message: String, } #[derive(Deserialize)] pub struct CreateLockRequest { pub oid: String, } #[derive(Serialize)] pub struct LockResponse { pub id: Uuid, pub path: String, pub locked_by: Uuid, pub locked_at: String, } pub struct LfsHandler { pub storage_path: PathBuf, pub model: RepoModel, pub namespace: String, pub db: AppDatabase, } impl LfsHandler { pub fn new( storage_path: PathBuf, model: RepoModel, namespace: String, db: AppDatabase, ) -> Self { Self { storage_path, model, namespace, db, } } fn get_lfs_storage_path(&self) -> PathBuf { self.storage_path.join(".lfs") } fn get_object_path(&self, oid: &str) -> PathBuf { let prefix = &oid[..2]; self.get_lfs_storage_path() .join("objects") .join(prefix) .join(oid) } fn build_object_url(&self, base_url: &str, oid: &str) -> String { format!( "{}/{}/{}.git/info/lfs/objects/{}", base_url, self.namespace, self.model.name, oid ) } pub async fn batch( &self, req: BatchRequest, base_url: &str, ) -> Result { let operation = req.operation.as_str(); if operation != "upload" && operation != "download" { return Err(GitError::InvalidOid(format!( "Invalid operation: {}", operation ))); } for obj in &req.objects { if obj.size > LFS_MAX_OBJECT_SIZE { return Err(GitError::InvalidOid(format!( "Object size {} exceeds maximum allowed size {}", obj.size, LFS_MAX_OBJECT_SIZE ))); } } let oids: Vec = req.objects.iter().map(|o| o.oid.clone()).collect(); let existing: Vec = sqlx::query_as::<_, RepoLfsObjectModel>( "SELECT repo, oid, size_bytes, storage_key, created_at \ FROM repo_lfs_object \ WHERE oid = ANY($1) AND repo = $2", ) .bind(&oids) .bind(self.model.id) .fetch_all(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))?; let existing_map: HashMap<&str, &RepoLfsObjectModel> = existing.iter().map(|m| (m.oid.as_str(), m)).collect(); let mut response_objects = Vec::with_capacity(req.objects.len()); for obj in req.objects { let existing = existing_map.get(obj.oid.as_str()); let mut actions = HashMap::new(); match operation { "upload" => { if existing.is_none() { let upload_url = self.build_object_url(base_url, &obj.oid); let token = Uuid::now_v7().to_string(); let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), format!("Bearer {}", token), ); actions.insert( "upload".to_string(), LfsAction { href: upload_url, header: Some(headers), expires_in: Some(LFS_AUTH_TOKEN_EXPIRY as i64), expires_at: None, }, ); } } "download" => match existing { Some(_) => { let download_url = self.build_object_url(base_url, &obj.oid); let token = Uuid::now_v7().to_string(); let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), format!("Bearer {}", token), ); actions.insert( "download".to_string(), LfsAction { href: download_url, header: Some(headers), expires_in: Some(LFS_AUTH_TOKEN_EXPIRY as i64), expires_at: None, }, ); } None => { response_objects.push(LfsObjectResponse { oid: obj.oid, size: obj.size, authenticated: None, actions: None, error: Some(LfsError { code: 404, message: "Object does not exist".to_string(), }), }); continue; } }, _ => {} } response_objects.push(LfsObjectResponse { oid: obj.oid, size: obj.size, authenticated: Some(true), actions: if actions.is_empty() { None } else { Some(actions) }, error: None, }); } Ok(BatchResponse { transfer: "basic".to_string(), objects: response_objects, hash_algo: req.hash_algo, }) } pub async fn batch_with_auth( &self, req: BatchRequest, base_url: &str, user_id: uuid::Uuid, cache: &AppCache, ) -> Result { let operation = req.operation.as_str(); if operation != "upload" && operation != "download" { return Err(GitError::InvalidOid(format!( "Invalid operation: {}", operation ))); } for obj in &req.objects { if obj.size > LFS_MAX_OBJECT_SIZE { return Err(GitError::InvalidOid(format!( "Object size {} exceeds maximum allowed size {}", obj.size, LFS_MAX_OBJECT_SIZE ))); } } let oids: Vec = req.objects.iter().map(|o| o.oid.clone()).collect(); let existing: Vec = sqlx::query_as::<_, RepoLfsObjectModel>( "SELECT repo, oid, size_bytes, storage_key, created_at \ FROM repo_lfs_object \ WHERE oid = ANY($1) AND repo = $2", ) .bind(&oids) .bind(self.model.id) .fetch_all(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))?; let existing_map: HashMap<&str, &RepoLfsObjectModel> = existing.iter().map(|m| (m.oid.as_str(), m)).collect(); let mut response_objects = Vec::with_capacity(req.objects.len()); for obj in req.objects { let existing = existing_map.get(obj.oid.as_str()); let mut actions = HashMap::new(); match operation { "upload" => { if existing.is_none() { let upload_url = self.build_object_url(base_url, &obj.oid); let token = Uuid::now_v7().to_string(); crate::http::lfs_routes::store_lfs_token( cache, &token, self.model.id, user_id, "upload", ) .await; let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), format!("Bearer {}", token), ); actions.insert( "upload".to_string(), LfsAction { href: upload_url, header: Some(headers), expires_in: Some(LFS_AUTH_TOKEN_EXPIRY as i64), expires_at: None, }, ); } } "download" => match existing { Some(_) => { let download_url = self.build_object_url(base_url, &obj.oid); let token = Uuid::now_v7().to_string(); crate::http::lfs_routes::store_lfs_token( cache, &token, self.model.id, user_id, "download", ) .await; let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), format!("Bearer {}", token), ); actions.insert( "download".to_string(), LfsAction { href: download_url, header: Some(headers), expires_in: Some(LFS_AUTH_TOKEN_EXPIRY as i64), expires_at: None, }, ); } None => { response_objects.push(LfsObjectResponse { oid: obj.oid, size: obj.size, authenticated: None, actions: None, error: Some(LfsError { code: 404, message: "Object does not exist".to_string(), }), }); continue; } }, _ => {} } response_objects.push(LfsObjectResponse { oid: obj.oid, size: obj.size, authenticated: Some(true), actions: if actions.is_empty() { None } else { Some(actions) }, error: None, }); } Ok(BatchResponse { transfer: "basic".to_string(), objects: response_objects, hash_algo: req.hash_algo, }) } pub async fn upload_object( &self, oid: &str, payload: web::Payload, ) -> Result { if !is_valid_lfs_oid(oid) { return Err(GitError::InvalidOid(format!( "Invalid OID format: {}", oid ))); } let object_path = self.get_object_path(oid); if let Some(parent) = object_path.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| { GitError::Internal(format!("Failed to create directory: {}", e)) })?; } let temp_path = object_path.with_extension("tmp"); let mut file = tokio::fs::File::create(&temp_path).await.map_err(|e| { GitError::Internal(format!("Failed to create temp file: {}", e)) })?; use futures_util::stream::StreamExt; use sha2::Digest; use tokio::io::AsyncWriteExt; let mut payload = payload; let mut size = 0i64; let mut hasher = sha2::Sha256::new(); while let Some(chunk) = payload.next().await { let chunk = chunk.map_err(|e| { GitError::Internal(format!("Payload error: {}", e)) })?; size += chunk.len() as i64; if size > LFS_MAX_OBJECT_SIZE { let _ = tokio::fs::remove_file(&temp_path).await; return Err(GitError::InvalidOid(format!( "Object size exceeds maximum allowed size {}", LFS_MAX_OBJECT_SIZE ))); } hasher.update(&chunk); if let Err(e) = file.write_all(&chunk).await { let _ = tokio::fs::remove_file(&temp_path).await; return Err(GitError::Internal(format!( "Failed to write file: {}", e ))); } } file.flush().await.map_err(|e| { GitError::Internal(format!("Failed to flush file: {}", e)) })?; drop(file); let hash_bytes = hasher.finalize(); let calculated_oid = hex::encode(hash_bytes.as_slice()); if calculated_oid != oid { let _ = tokio::fs::remove_file(&temp_path).await; return Err(GitError::InvalidOid(format!( "OID mismatch: expected {}, got {}", oid, calculated_oid ))); } if let Err(e) = tokio::fs::rename(&temp_path, &object_path).await { let _ = tokio::fs::remove_file(&temp_path).await; return Err(GitError::Internal(format!( "Failed to move file: {}", e ))); } let now = chrono::Utc::now(); sqlx::query( "INSERT INTO repo_lfs_object (repo, oid, size_bytes, storage_key, created_at) \ VALUES ($1, $2, $3, $4, $5)", ) .bind(self.model.id) .bind(oid) .bind(size) .bind(object_path.to_string_lossy().to_string()) .bind(now) .execute(self.db.writer()) .await .map_err(|e| GitError::Internal(e.to_string()))?; Ok(HttpResponse::Ok().finish()) } pub async fn download_object( &self, oid: &str, ) -> Result { if !is_valid_lfs_oid(oid) { return Err(GitError::InvalidOid(format!( "Invalid OID format: {}", oid ))); } let obj = sqlx::query_as::<_, RepoLfsObjectModel>( "SELECT repo, oid, size_bytes, storage_key, created_at \ FROM repo_lfs_object \ WHERE oid = $1 AND repo = $2", ) .bind(oid) .bind(self.model.id) .fetch_optional(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))? .ok_or_else(|| GitError::NotFound("Object not found".to_string()))?; let expected_base = self.get_lfs_storage_path(); let obj_path = PathBuf::from(&obj.storage_key); if !obj_path.starts_with(&expected_base) { tracing::error!( "LFS object path outside storage directory: {}", obj.storage_key ); return Err(GitError::AuthFailed( "Invalid object path".to_string(), )); } let file = tokio::fs::File::open(&obj_path).await.map_err(|e| { GitError::Internal(format!("Failed to open file: {}", e)) })?; use actix_web::body::BodyStream; use futures_util::stream; use tokio::io::AsyncReadExt; let chunk_size: usize = 65536; let stream = stream::unfold(file, move |mut file| async move { let mut buffer = vec![0u8; chunk_size]; match file.read(&mut buffer).await { Ok(0) => None, Ok(n) => { buffer.truncate(n); Some(( Ok::<_, std::io::Error>(actix_web::web::Bytes::from( buffer, )), file, )) } Err(e) => Some((Err(e), file)), } }); Ok(HttpResponse::Ok() .content_type("application/octet-stream") .insert_header(("Content-Length", obj.size_bytes.to_string())) .body(BodyStream::new(stream))) } pub async fn lock_object( &self, oid: &str, user_id: uuid::Uuid, ) -> Result { if !is_valid_lfs_oid(oid) { return Err(GitError::InvalidOid(format!( "Invalid OID format: {}", oid ))); } let now = chrono::Utc::now(); let lock_id = Uuid::now_v7(); let result = sqlx::query( "INSERT INTO repo_lfs_lock (id, repo, path, locked_by, ref_name, created_at) \ VALUES ($1, $2, $3, $4, NULL, $5)", ) .bind(lock_id) .bind(self.model.id) .bind(oid) .bind(user_id) .bind(now) .execute(self.db.writer()) .await; match result { Ok(_) => Ok(LockResponse { id: lock_id, path: oid.to_string(), locked_by: user_id, locked_at: now.to_rfc3339(), }), Err(e) => { let err_msg = format!("{}", e); if err_msg.contains("duplicate key") || err_msg.contains("23505") { return Err(GitError::Locked("Already locked".to_string())); } Err(GitError::Internal(format!("DB error: {}", e))) } } } pub async fn unlock_object( &self, lock_id: &str, user_id: uuid::Uuid, ) -> Result<(), GitError> { let lock_uuid = Uuid::parse_str(lock_id) .map_err(|_| GitError::NotFound("Invalid lock ID".to_string()))?; let existing = sqlx::query_as::<_, RepoLfsLockModel>( "SELECT id, repo, path, locked_by, ref_name, created_at \ FROM repo_lfs_lock \ WHERE id = $1 AND repo = $2", ) .bind(lock_uuid) .bind(self.model.id) .fetch_optional(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))? .ok_or_else(|| GitError::NotFound("Lock not found".to_string()))?; if existing.locked_by != user_id && existing.locked_by != self.model.created_by { return Err(GitError::PermissionDenied( "Not allowed to unlock".to_string(), )); } sqlx::query("DELETE FROM repo_lfs_lock WHERE id = $1 AND repo = $2") .bind(lock_uuid) .bind(self.model.id) .execute(self.db.writer()) .await .map_err(|e| GitError::Internal(e.to_string()))?; Ok(()) } pub async fn list_locks( &self, maybe_oid: Option<&str>, ) -> Result, GitError> { let rows: Vec = if let Some(oid) = maybe_oid { sqlx::query_as::<_, RepoLfsLockModel>( "SELECT id, repo, path, locked_by, ref_name, created_at \ FROM repo_lfs_lock \ WHERE repo = $1 AND path = $2", ) .bind(self.model.id) .bind(oid) .fetch_all(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))? } else { sqlx::query_as::<_, RepoLfsLockModel>( "SELECT id, repo, path, locked_by, ref_name, created_at \ FROM repo_lfs_lock \ WHERE repo = $1", ) .bind(self.model.id) .fetch_all(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))? }; Ok(rows .into_iter() .map(|r| LockResponse { id: r.id, path: r.path, locked_by: r.locked_by, locked_at: r.created_at.to_rfc3339(), }) .collect()) } pub async fn get_lock( &self, lock_id: &str, ) -> Result { let lock_uuid = Uuid::parse_str(lock_id) .map_err(|_| GitError::NotFound("Invalid lock ID".to_string()))?; let r = sqlx::query_as::<_, RepoLfsLockModel>( "SELECT id, repo, path, locked_by, ref_name, created_at \ FROM repo_lfs_lock \ WHERE id = $1 AND repo = $2", ) .bind(lock_uuid) .bind(self.model.id) .fetch_optional(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))? .ok_or_else(|| GitError::NotFound("Lock not found".to_string()))?; Ok(LockResponse { id: r.id, path: r.path, locked_by: r.locked_by, locked_at: r.created_at.to_rfc3339(), }) } }