From 2fe3566848e4b11d5f17b4baa0fa8ea9f7c06754 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Tue, 22 Apr 2025 13:28:13 +0200 Subject: [PATCH] httpd: Add archive endpoint for tarballs --- radicle-httpd/src/error.rs | 16 +++++++++ radicle-httpd/src/raw.rs | 68 +++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/radicle-httpd/src/error.rs b/radicle-httpd/src/error.rs index 1dc002a90..de549ba72 100644 --- a/radicle-httpd/src/error.rs +++ b/radicle-httpd/src/error.rs @@ -73,6 +73,22 @@ pub enum RawError { #[error(transparent)] Surf(#[from] radicle_surf::Error), + /// Git2 error. + #[error(transparent)] + Git2(#[from] radicle::git::raw::Error), + + /// Payload error. + #[error(transparent)] + ProjectPayload(#[from] radicle::identity::PayloadError), + + /// Io error. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Archive error. + #[error("git archive error: {0}")] + Archive(String), + /// Git error. #[error(transparent)] Git(#[from] radicle::git::ext::Error), diff --git a/radicle-httpd/src/raw.rs b/radicle-httpd/src/raw.rs index 19c1d4fc7..0c414137b 100644 --- a/radicle-httpd/src/raw.rs +++ b/radicle-httpd/src/raw.rs @@ -1,3 +1,5 @@ +use std::process::Command; +use std::str::FromStr; use std::sync::Arc; use axum::extract::{Query, State}; @@ -8,10 +10,11 @@ use axum::Router; use hyper::HeaderMap; use radicle_surf::blob::{Blob, BlobRef}; +use radicle::git::Oid; use radicle::prelude::RepoId; use radicle::profile::Profile; use radicle::storage::{ReadRepository, ReadStorage}; -use radicle_surf::{Oid, Repository}; +use radicle_surf::Repository; use crate::api::query::RawQuery; use crate::axum_extra::Path; @@ -95,6 +98,7 @@ pub fn router(profile: Arc) -> Router { Router::new() .route("/:rid/:sha/*path", get(file_by_commit_handler)) .route("/:rid/head/*path", get(file_by_canonical_head_handler)) + .route("/:rid/archive/*refspec", get(archive_by_refspec_handler)) .route("/:rid/blobs/:oid", get(file_by_oid_handler)) .with_state(profile) } @@ -117,6 +121,68 @@ async fn file_by_commit_handler( blob_response(blob, path) } +async fn archive_by_refspec_handler( + Path((rid, refspec)): Path<(RepoId, String)>, + State(profile): State>, +) -> impl IntoResponse { + let storage = &profile.storage; + let repo = storage.repository(rid)?; + + // Don't allow downloading tarballs for private repos. + if repo.identity_doc()?.visibility().is_private() { + return Err(Error::NotFound); + } + + let doc = repo.identity_doc()?; + let project = doc.project()?; + let repo_name = project.name(); + + // Remove possible .tar.gz suffix + let refspec = if refspec.ends_with(".tar.gz") { + refspec.trim_end_matches(".tar.gz") + } else { + &refspec + }; + + let oid = match Oid::from_str(&refspec) { + Ok(oid) => oid, + Err(_) => repo + .backend + .resolve_reference_from_short_name(&refspec) + .map(|reference| reference.target())? + .ok_or(Error::NotFound)? + .into(), + }; + + // SAFETY: Git command is available on the system, so we can safely unwrap. + let output = Command::new("git") + .arg("archive") + .arg("--format=tar.gz") + .arg(oid.to_string()) + .current_dir(repo.path()) + .output()?; + + if !output.status.success() { + return Err(Error::Archive(format!( + "{}; code={}", + String::from_utf8_lossy(&output.stderr).trim(), + output.status.code().unwrap_or(0) + ))); + } + + let mut response_headers = HeaderMap::new(); + response_headers.insert("Content-Type", HeaderValue::from_str("application/gzip")?); + response_headers.insert( + "Content-Disposition", + HeaderValue::from_str(&format!( + "attachment; filename={}-{}.tar.gz", + repo_name, + refspec.replace(|c: char| !c.is_ascii(), "_") + ))?, + ); + Ok::<_, Error>((StatusCode::OK, response_headers, output.stdout)) +} + async fn file_by_canonical_head_handler( Path((rid, path)): Path<(RepoId, String)>, State(profile): State>,