Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
radicle-explorer radicle-httpd src raw.rs
use std::process::Command;
use std::str::FromStr;
use std::sync::Arc;

use axum::extract::{Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
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::Repository;

use crate::api::query::RawQuery;
use crate::axum_extra::Path;
use crate::error::RawError as Error;

const MAX_BLOB_SIZE: usize = 10_485_760;

/// Values for `git archive --format` that we support.
#[derive(Debug, Default)]
enum ArchiveFormat {
    Tar,
    // NOTE: Even though `git archive` would use `tar` as the default format,
    // we use `tar.gz` as the default format for HTTP responses, to benefit
    // from compression when downloading large repositories. It was also used
    // before the `ArchiveFormat` enum was introduced, so this default will
    // also surprise less.
    #[default]
    TarGz,
    Zip,
}

impl ArchiveFormat {
    const fn as_str(&self) -> &'static str {
        match self {
            ArchiveFormat::Tar => "tar",
            ArchiveFormat::TarGz => "tar.gz",
            ArchiveFormat::Zip => "zip",
        }
    }

    const fn extension(&self) -> &'static str {
        match self {
            ArchiveFormat::Tar => ".tar",
            ArchiveFormat::TarGz => ".tar.gz",
            ArchiveFormat::Zip => ".zip",
        }
    }

    /// The MIME type associated with this archive format.
    /// See <https://developer.mozilla.org/docs/Web/HTTP/Guides/MIME_types/Common_types>.
    const fn mime_type(&self) -> &'static str {
        match self {
            ArchiveFormat::Tar => "application/x-tar",
            ArchiveFormat::TarGz => "application/gzip",
            ArchiveFormat::Zip => "application/zip",
        }
    }

    /// Detect the archive format from the suffix of the given string.
    /// If a supported suffix is found, the suffix is stripped from the string
    /// and the corresponding format is returned.
    /// Otherwise, the original string is returned and `None` is returned for
    /// the format.
    fn detect(s: &str) -> (&str, Option<Self>) {
        if let Some(stripped) = s.strip_suffix(ArchiveFormat::Tar.extension()) {
            (stripped, Some(ArchiveFormat::Tar))
        } else if let Some(stripped) = s.strip_suffix(ArchiveFormat::TarGz.extension()) {
            (stripped, Some(ArchiveFormat::TarGz))
        } else if let Some(stripped) = s.strip_suffix(ArchiveFormat::Zip.extension()) {
            (stripped, Some(ArchiveFormat::Zip))
        } else {
            (s, None)
        }
    }
}

#[derive(serde::Deserialize)]
#[serde(default)]
struct PrefixQuery {
    /// Whether to include a prefix in the archive, i.e. whether to specify
    /// `git archive --prefix`. Defaults to `true`.
    prefix: bool,
}

impl Default for PrefixQuery {
    fn default() -> Self {
        Self { prefix: true }
    }
}

enum Committish<'a> {
    Oid(Oid),
    Ref(&'a str),
}

pub fn router(profile: Arc<Profile>) -> Router {
    Router::new()
        .route("/{rid}/{sha}", get(commit_handler))
        .route("/{rid}/{sha}/{*path}", get(file_by_commit_handler))
        .route("/{rid}/head/{*path}", get(file_by_canonical_head_handler))
        .route("/{rid}/archive/{*refname}", get(archive_by_refname_handler))
        .route("/{rid}/blobs/{oid}", get(file_by_oid_handler))
        .with_state(profile)
}

async fn commit_handler(
    Path((rid, sha)): Path<(RepoId, String)>,
    Query(q): Query<PrefixQuery>,
    State(profile): State<Arc<Profile>>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
    let storage = &profile.storage;
    let repo = storage.repository(rid)?;

    // Do not allow accessing private repos.
    if repo.identity_doc()?.visibility().is_private() {
        return Err(Error::NotFound);
    }

    let (sha, Some(format)) = ArchiveFormat::detect(&sha) else {
        return Err(Error::NotFound);
    };

    let Ok(oid) = Oid::from_str(sha) else {
        return Err(Error::BadRequest);
    };

    archive_by_committish(rid, Committish::Oid(oid), q.prefix, format, profile).await
}

async fn file_by_commit_handler(
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
    State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
    let storage = &profile.storage;
    let repo = storage.repository(rid)?;

    // Don't allow downloading raw files for private repos.
    if repo.identity_doc()?.visibility().is_private() {
        return Err(Error::NotFound);
    }

    let repo: Repository = repo.backend.into();
    let blob = repo.blob(
        radicle_surf::Oid::from(radicle::git::raw::Oid::from(sha)),
        &path,
    )?;

    blob_response(blob, &path)
}

async fn archive_by_refname_handler(
    Path((rid, refname)): Path<(RepoId, String)>,
    Query(q): Query<PrefixQuery>,
    State(profile): State<Arc<Profile>>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
    let (refname, format) = ArchiveFormat::detect(&refname);
    archive_by_committish(
        rid,
        Committish::Ref(refname),
        q.prefix,
        format.unwrap_or_default(),
        profile,
    )
    .await
}

async fn archive_by_committish(
    rid: RepoId,
    committish: Committish<'_>,
    use_prefix: bool,
    format: ArchiveFormat,
    profile: Arc<Profile>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
    let storage = &profile.storage;
    let repo = storage.repository(rid)?;

    // Do not 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();

    let oid = match committish {
        Committish::Oid(oid) => oid,
        Committish::Ref(refname) => repo
            .backend
            .resolve_reference_from_short_name(refname)
            .map(|reference| reference.target())?
            .ok_or(Error::NotFound)?
            .into(),
    };

    // Build a prefix for the archive, which includes the
    // refname (if one was given):
    //
    // Without refname:   <repo-name>-<oid>
    // With    refname:   <repo-name>-<refname>
    let prefix = {
        let mut build = String::from(repo_name);
        build.push('-');

        build.push_str(&match committish {
            Committish::Oid(oid) => oid.to_string(),
            Committish::Ref(refname) => {
                // NOTE: Sanitize refnames according to
                // <https://git-scm.com/docs/git-check-ref-format>
                refname.replace("/", "-")
            }
        });

        build
    };

    let mut command = Command::new("git");

    command
        .arg("archive")
        .arg(format!("--format={}", format.as_str()));

    if use_prefix {
        command.arg(format!("--prefix={prefix}/"));
    }

    let output = command
        .arg(oid.to_string())
        .arg("--")
        .current_dir(repo.path())
        .output()?;

    if !output.status.success() {
        return Err(Error::Archive(
            output.status,
            String::from_utf8_lossy(&output.stderr).to_string(),
        ));
    }

    let mut response_headers = HeaderMap::new();
    response_headers.insert("Content-Type", HeaderValue::from_str(format.mime_type())?);
    response_headers.insert(
        "Content-Disposition",
        HeaderValue::from_str(&format!(
            "attachment; filename=\"{prefix}{}\"",
            format.extension()
        ))?,
    );
    Ok::<_, Error>((StatusCode::OK, response_headers, output.stdout))
}

async fn file_by_canonical_head_handler(
    Path((rid, path)): Path<(RepoId, String)>,
    State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
    let storage = &profile.storage;
    let repo = storage.repository(rid)?;

    // Don't allow downloading raw files for private repos.
    if repo.identity_doc()?.visibility().is_private() {
        return Err(Error::NotFound);
    }

    let (_, sha) = repo.head()?;
    let repo: Repository = repo.backend.into();
    let blob = repo.blob(
        radicle_surf::Oid::from(radicle::git::raw::Oid::from(sha)),
        &path,
    )?;

    blob_response(blob, &path)
}

fn blob_response(
    blob: Blob<BlobRef>,
    path: &str,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
    let mut response_headers = HeaderMap::new();
    if blob.size() > MAX_BLOB_SIZE {
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
    }

    let mime = mime_guess::from_path(path)
        .first_raw()
        .or_else(|| infer::get(blob.content()).map(|i| i.mime_type()))
        .unwrap_or("application/octet-stream");

    response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(mime)?);

    Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_owned()))
}

async fn file_by_oid_handler(
    Path((rid, oid)): Path<(RepoId, Oid)>,
    State(profile): State<Arc<Profile>>,
    Query(_qs): Query<RawQuery>,
) -> impl IntoResponse {
    let storage = &profile.storage;
    let repo = storage.repository(rid)?;

    // Don't allow downloading raw files for private repos.
    if repo.identity_doc()?.visibility().is_private() {
        return Err(Error::NotFound);
    }

    let blob = repo.blob(oid)?;
    let content = blob.content();
    let mime = infer::get(content).map(|i| i.mime_type().to_string());
    let mut response_headers = HeaderMap::new();

    if blob.size() > MAX_BLOB_SIZE {
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
    }

    response_headers.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_str(&mime.unwrap_or("application/octet-stream".to_string()))?,
    );

    Ok::<_, Error>((StatusCode::OK, response_headers, content.to_vec()))
}

#[cfg(test)]
mod routes {
    use axum::http::StatusCode;

    use crate::test::{self, get, RID, RID_PRIVATE};
    use radicle::storage::ReadStorage;

    #[tokio::test]
    async fn test_file_handler() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test::seed(tmp.path());
        let app = super::router(ctx.profile().to_owned());

        let response = get(&app, format!("/{RID}/head/dir1/README")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(response.body().await, "Hello World from dir1!\n");

        // Make sure the repo exists in storage.
        ctx.profile()
            .storage
            .repository(RID_PRIVATE.parse().unwrap())
            .unwrap();

        let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}