raw.rs
1 use std::process::Command; 2 use std::str::FromStr; 3 use std::sync::Arc; 4 5 use axum::extract::{Query, State}; 6 use axum::http::{header, HeaderValue, StatusCode}; 7 use axum::response::IntoResponse; 8 use axum::routing::get; 9 use axum::Router; 10 use hyper::HeaderMap; 11 use radicle_surf::blob::{Blob, BlobRef}; 12 13 use radicle::git::Oid; 14 use radicle::prelude::RepoId; 15 use radicle::profile::Profile; 16 use radicle::storage::{ReadRepository, ReadStorage}; 17 use radicle_surf::Repository; 18 19 use crate::api::query::RawQuery; 20 use crate::axum_extra::Path; 21 use crate::error::RawError as Error; 22 23 const MAX_BLOB_SIZE: usize = 10_485_760; 24 25 const ARCHIVE_SUFFIX: &str = ".tar.gz"; 26 27 pub fn router(profile: Arc<Profile>) -> Router { 28 Router::new() 29 .route("/{rid}/{sha}", get(commit_handler)) 30 .route("/{rid}/{sha}/{*path}", get(file_by_commit_handler)) 31 .route("/{rid}/head/{*path}", get(file_by_canonical_head_handler)) 32 .route("/{rid}/archive/{*refname}", get(archive_by_refname_handler)) 33 .route("/{rid}/blobs/{oid}", get(file_by_oid_handler)) 34 .with_state(profile) 35 } 36 37 async fn commit_handler( 38 Path((rid, sha)): Path<(RepoId, String)>, 39 State(profile): State<Arc<Profile>>, 40 ) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> { 41 let storage = &profile.storage; 42 let repo = storage.repository(rid)?; 43 44 // Don't allow accessing private repos. 45 if repo.identity_doc()?.visibility().is_private() { 46 return Err(Error::NotFound); 47 } 48 49 if !sha.ends_with(ARCHIVE_SUFFIX) { 50 return Err(Error::NotFound); 51 } 52 53 let sha = sha.trim_end_matches(ARCHIVE_SUFFIX); 54 55 if Oid::from_str(sha).is_err() { 56 return Err(Error::NotFound); 57 } 58 59 archive_by_refname(rid, sha.to_string(), profile).await 60 } 61 62 async fn file_by_commit_handler( 63 Path((rid, sha, path)): Path<(RepoId, Oid, String)>, 64 State(profile): State<Arc<Profile>>, 65 ) -> impl IntoResponse { 66 let storage = &profile.storage; 67 let repo = storage.repository(rid)?; 68 69 // Don't allow downloading raw files for private repos. 70 if repo.identity_doc()?.visibility().is_private() { 71 return Err(Error::NotFound); 72 } 73 74 let repo: Repository = repo.backend.into(); 75 let blob = repo.blob( 76 radicle_surf::Oid::from(radicle::git::raw::Oid::from(sha)), 77 &path, 78 )?; 79 80 blob_response(blob, &path) 81 } 82 83 async fn archive_by_refname_handler( 84 Path((rid, refname)): Path<(RepoId, String)>, 85 State(profile): State<Arc<Profile>>, 86 ) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> { 87 archive_by_refname(rid, refname, profile).await 88 } 89 90 async fn archive_by_refname( 91 rid: RepoId, 92 refname: String, 93 profile: Arc<Profile>, 94 ) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> { 95 let storage = &profile.storage; 96 let repo = storage.repository(rid)?; 97 98 // Don't allow downloading tarballs for private repos. 99 if repo.identity_doc()?.visibility().is_private() { 100 return Err(Error::NotFound); 101 } 102 103 let doc = repo.identity_doc()?; 104 let project = doc.project()?; 105 let repo_name = project.name(); 106 107 let (oid, via_refname) = match Oid::from_str(&refname) { 108 Ok(oid) => (oid, false), 109 Err(_) => ( 110 repo.backend 111 .resolve_reference_from_short_name(&refname) 112 .map(|reference| reference.target())? 113 .ok_or(Error::NotFound)? 114 .into(), 115 true, 116 ), 117 }; 118 119 let output = Command::new("git") 120 .arg("archive") 121 .arg("--format=tar.gz") 122 .arg(oid.to_string()) 123 .current_dir(repo.path()) 124 .output()?; 125 126 if !output.status.success() { 127 return Err(Error::Archive( 128 output.status, 129 String::from_utf8_lossy(&output.stderr).to_string(), 130 )); 131 } 132 133 // Build a filename for the archive, which includes the 134 // refname (if one was given): 135 // 136 // Without refname: <repo-name>-<oid>.tar.gz 137 // With refname: <repo-name>-<refname>--<oid>.tar.gz 138 let filename = { 139 let mut build = String::from(repo_name); 140 build.push('-'); 141 142 if via_refname { 143 // NOTE: Sanitize refnames according to 144 // <https://git-scm.com/docs/git-check-ref-format> 145 build.push_str(&refname.replace("/", "__")); 146 build.push('-'); 147 } 148 149 build.push_str(oid.to_string().as_str()); 150 build.push_str(ARCHIVE_SUFFIX); 151 build 152 }; 153 154 let mut response_headers = HeaderMap::new(); 155 response_headers.insert("Content-Type", HeaderValue::from_str("application/gzip")?); 156 response_headers.insert( 157 "Content-Disposition", 158 HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))?, 159 ); 160 Ok::<_, Error>((StatusCode::OK, response_headers, output.stdout)) 161 } 162 163 async fn file_by_canonical_head_handler( 164 Path((rid, path)): Path<(RepoId, String)>, 165 State(profile): State<Arc<Profile>>, 166 ) -> impl IntoResponse { 167 let storage = &profile.storage; 168 let repo = storage.repository(rid)?; 169 170 // Don't allow downloading raw files for private repos. 171 if repo.identity_doc()?.visibility().is_private() { 172 return Err(Error::NotFound); 173 } 174 175 let (_, sha) = repo.head()?; 176 let repo: Repository = repo.backend.into(); 177 let blob = repo.blob( 178 radicle_surf::Oid::from(radicle::git::raw::Oid::from(sha)), 179 &path, 180 )?; 181 182 blob_response(blob, &path) 183 } 184 185 fn blob_response( 186 blob: Blob<BlobRef>, 187 path: &str, 188 ) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> { 189 let mut response_headers = HeaderMap::new(); 190 if blob.size() > MAX_BLOB_SIZE { 191 return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![])); 192 } 193 194 let mime = mime_guess::from_path(path) 195 .first_raw() 196 .or_else(|| infer::get(blob.content()).map(|i| i.mime_type())) 197 .unwrap_or("application/octet-stream"); 198 199 response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(mime)?); 200 201 Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_owned())) 202 } 203 204 async fn file_by_oid_handler( 205 Path((rid, oid)): Path<(RepoId, Oid)>, 206 State(profile): State<Arc<Profile>>, 207 Query(_qs): Query<RawQuery>, 208 ) -> impl IntoResponse { 209 let storage = &profile.storage; 210 let repo = storage.repository(rid)?; 211 212 // Don't allow downloading raw files for private repos. 213 if repo.identity_doc()?.visibility().is_private() { 214 return Err(Error::NotFound); 215 } 216 217 let blob = repo.blob(oid)?; 218 let content = blob.content(); 219 let mime = infer::get(content).map(|i| i.mime_type().to_string()); 220 let mut response_headers = HeaderMap::new(); 221 222 if blob.size() > MAX_BLOB_SIZE { 223 return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![])); 224 } 225 226 response_headers.insert( 227 header::CONTENT_TYPE, 228 HeaderValue::from_str(&mime.unwrap_or("application/octet-stream".to_string()))?, 229 ); 230 231 Ok::<_, Error>((StatusCode::OK, response_headers, content.to_vec())) 232 } 233 234 #[cfg(test)] 235 mod routes { 236 use axum::http::StatusCode; 237 238 use crate::test::{self, get, RID, RID_PRIVATE}; 239 use radicle::storage::ReadStorage; 240 241 #[tokio::test] 242 async fn test_file_handler() { 243 let tmp = tempfile::tempdir().unwrap(); 244 let ctx = test::seed(tmp.path()); 245 let app = super::router(ctx.profile().to_owned()); 246 247 let response = get(&app, format!("/{RID}/head/dir1/README")).await; 248 249 assert_eq!(response.status(), StatusCode::OK); 250 assert_eq!(response.body().await, "Hello World from dir1!\n"); 251 252 // Make sure the repo exists in storage. 253 ctx.profile() 254 .storage 255 .repository(RID_PRIVATE.parse().unwrap()) 256 .unwrap(); 257 258 let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await; 259 assert_eq!(response.status(), StatusCode::NOT_FOUND); 260 } 261 }