/ src / raw.rs
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  }