use crate::AppService; use crate::error::AppError; use crate::git::{ArchiveEntry, ArchiveFormat, ArchiveSummary}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct ArchiveQuery { pub commit_oid: String, pub format: String, #[serde(default)] pub prefix: Option, #[serde(default)] pub max_depth: Option, #[serde(default)] pub path_filter: Option, } impl ArchiveQuery { fn to_archive_format(&self) -> Result { match self.format.to_lowercase().as_str() { "tar" => Ok(ArchiveFormat::Tar), "tar.gz" | "tgz" => Ok(ArchiveFormat::TarGz), "zip" => Ok(ArchiveFormat::Zip), _ => Err(AppError::InternalServerError(format!( "unsupported archive format: {}", self.format ))), } } fn cache_key(&self) -> String { let prefix = self.prefix.as_deref().unwrap_or(""); let filter = self.path_filter.as_deref().unwrap_or(""); let depth = self.max_depth.map_or("0".to_string(), |d| d.to_string()); if prefix.is_empty() && filter.is_empty() && self.max_depth.is_none() { String::new() } else { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut h = DefaultHasher::new(); (prefix, filter, depth).hash(&mut h); format!("-{:x}", h.finish()) } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveListResponse { pub commit_oid: String, pub entries: Vec, pub total_entries: usize, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveEntryResponse { pub path: String, pub oid: String, pub size: u64, pub mode: u32, } impl From for ArchiveEntryResponse { fn from(e: ArchiveEntry) -> Self { Self { path: e.path, oid: e.oid, size: e.size, mode: e.mode, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveSummaryResponse { pub commit_oid: String, pub format: String, pub total_entries: usize, pub total_size: u64, } impl From for ArchiveSummaryResponse { fn from(s: ArchiveSummary) -> Self { let format_str = match s.format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; Self { commit_oid: s.commit_oid, format: format_str.to_string(), total_entries: s.total_entries, total_size: s.total_size, } } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ArchiveResponse { pub commit_oid: String, pub format: String, pub size: usize, pub data: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveCachedResponse { pub commit_oid: String, pub format: String, pub cached: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveInvalidateResponse { pub commit_oid: String, pub format: String, pub invalidated: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ArchiveInvalidateAllResponse { pub commit_oid: String, pub count: usize, } impl AppService { pub async fn git_archive( &self, namespace: String, repo_name: String, query: ArchiveQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let format = query.to_archive_format()?; let format_str = match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; let _commit_oid = git::CommitOid::new(&query.commit_oid); let cache_key = format!( "git:archive:{}:{}:{}:{}:{}", namespace, repo_name, query.commit_oid, format_str, query.cache_key(), ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str::(&cached) { return Ok(cached); } } } let _opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); let arch_prefix = query.prefix.clone(); let arch_max_depth = query.max_depth; let arch_commit_oid = query.commit_oid.clone(); let arch_format_str = format_str.to_string(); let data = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let commit_oid = git::CommitOid::new(&arch_commit_oid); let format = match arch_format_str.as_str() { "tar" => ArchiveFormat::Tar, "tar.gz" => ArchiveFormat::TarGz, "zip" => ArchiveFormat::Zip, _ => unreachable!(), }; let opts = git::ArchiveOptions::new() .prefix(arch_prefix.as_deref().unwrap_or("")) .max_depth(arch_max_depth.unwrap_or(usize::MAX)); domain.archive(&commit_oid, format, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let data_b64 = BASE64.encode(&data); let response = ArchiveResponse { commit_oid: query.commit_oid, format: format_str.to_string(), size: data.len(), data: data_b64, }; if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 60 * 60, ) .await { tracing::debug!(error = ?e, "cache set failed (non-fatal)"); } } Ok(response) } pub async fn git_archive_list( &self, namespace: String, repo_name: String, query: ArchiveQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let list_commit_oid = query.commit_oid.clone(); let list_prefix = query.prefix.clone(); let list_max_depth = query.max_depth; let entries = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let commit_oid = git::CommitOid::new(&list_commit_oid); let opts = git::ArchiveOptions::new() .prefix(list_prefix.as_deref().unwrap_or("")) .max_depth(list_max_depth.unwrap_or(usize::MAX)); domain.archive_list(&commit_oid, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let max_entries = 10000; let entry_responses: Vec = entries .into_iter() .take(max_entries) .map(ArchiveEntryResponse::from) .collect(); let total_entries = entry_responses.len(); Ok(ArchiveListResponse { commit_oid: query.commit_oid, entries: entry_responses, total_entries, }) } pub async fn git_archive_summary( &self, namespace: String, repo_name: String, query: ArchiveQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let format = query.to_archive_format()?; let format_str = match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; let _commit_oid = git::CommitOid::new(&query.commit_oid); let _opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); let sum_commit_oid = query.commit_oid.clone(); let sum_prefix = query.prefix.clone(); let sum_max_depth = query.max_depth; let sum_format_str = format_str.to_string(); let mut summary = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let commit_oid = git::CommitOid::new(&sum_commit_oid); let format = match sum_format_str.as_str() { "tar" => ArchiveFormat::Tar, "tar.gz" => ArchiveFormat::TarGz, "zip" => ArchiveFormat::Zip, _ => ArchiveFormat::Tar, }; let opts = git::ArchiveOptions::new() .prefix(sum_prefix.as_deref().unwrap_or("")) .max_depth(sum_max_depth.unwrap_or(usize::MAX)); domain.archive_summary(&commit_oid, format, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; summary.format = format; Ok(ArchiveSummaryResponse { commit_oid: query.commit_oid, format: format_str.to_string(), total_entries: summary.total_entries, total_size: summary.total_size, }) } pub async fn git_archive_cached( &self, namespace: String, repo_name: String, query: ArchiveQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let format = query.to_archive_format()?; let format_str = match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; let commit_oid = git::CommitOid::new(&query.commit_oid); let opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); let domain = git::GitDomain::from_model(repo)?; let cached = domain.archive_cached(&commit_oid, format, Some(opts)); Ok(ArchiveCachedResponse { commit_oid: query.commit_oid, format: format_str.to_string(), cached, }) } pub async fn git_archive_invalidate( &self, namespace: String, repo_name: String, query: ArchiveQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let format = query.to_archive_format()?; let format_str = match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; let commit_oid = git::CommitOid::new(&query.commit_oid); let opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); let domain = git::GitDomain::from_model(repo)?; let invalidated = domain.archive_invalidate(&commit_oid, format, Some(opts))?; Ok(ArchiveInvalidateResponse { commit_oid: query.commit_oid, format: format_str.to_string(), invalidated, }) } pub async fn git_archive_invalidate_all( &self, namespace: String, repo_name: String, commit_oid: String, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let commit = git::CommitOid::new(&commit_oid); let domain = git::GitDomain::from_model(repo)?; let count = domain.archive_invalidate_all(&commit)?; Ok(ArchiveInvalidateAllResponse { commit_oid, count }) } }