AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/server/forks_api.rs

Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson

//! Forks and mirrors (OLI-276): fork a hosted repository, list forks, pull
//! from the upstream, and host a mirror of an external URL.

use axum::{
    Json,
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
};
use serde::Deserialize;

use super::access_api::{Identity, identify, role_on};
use super::{ApiError, AppState, Envelope, ok, unix_time};
use crate::{
    access::{self, Role},
    db::Repository,
    forks::{self, SyncOutcome},
};

fn require(
    identity: &Identity,
    state: &AppState,
    repository: &str,
    role: Role,
) -> Result<(), ApiError> {
    match role_on(identity, state, repository)? {
        Some(held) if held.allows(role) => Ok(()),
        Some(_) => Err(ApiError::forbidden(format!(
            "{} access to {repository} is required",
            role.as_str()
        ))),
        None => Err(ApiError::forbidden(format!("no access to {repository}"))),
    }
}

/// Reading a public repository needs no role.
fn can_read(
    identity: &Identity,
    state: &AppState,
    repository: &Repository,
) -> Result<(), ApiError> {
    if repository.visibility == "public" {
        return Ok(());
    }
    require(identity, state, &repository.name, Role::Read)
}

#[derive(Deserialize)]
pub(super) struct ForkRequest {
    name: String,
}

pub(super) async fn fork(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(source): Path<String>,
    Json(body): Json<ForkRequest>,
) -> Result<(StatusCode, Json<Envelope<Repository>>), ApiError> {
    let identity = identify(&headers, &state)?;
    let upstream = state
        .database
        .repository(&source)
        .map_err(|_| ApiError::not_found(format!("unknown repository {source}")))?;
    can_read(&identity, &state, &upstream)?;
    let owner = match &identity {
        Identity::Session { email, .. } => Some(email.clone()),
        Identity::Token(principal) => principal.subject.clone(),
        Identity::Master => None,
    };
    if matches!(&identity, Identity::Token(p) if p.subject.is_none()) {
        return Err(ApiError::forbidden("deploy keys cannot fork"));
    }
    let database = state.database.clone();
    let root = state.hosted_root.clone();
    let name = body.name.clone();
    let forked = state
        .blocking_git
        .run(move || {
            forks::fork(&database, &root, &source, &name).map_err(|error| {
                if error.to_string().contains("already exists") {
                    ApiError::conflict(error.to_string())
                } else {
                    ApiError::bad(error)
                }
            })
        })
        .await?;
    if let Some(owner) = owner {
        access::set_collaborator(&state.database, &forked.name, &owner, Role::Admin)
            .map_err(ApiError::internal)?;
    }
    Ok((StatusCode::CREATED, ok(forked)))
}

pub(super) async fn list_forks(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(source): Path<String>,
) -> Result<Json<Envelope<Vec<Repository>>>, ApiError> {
    let identity = identify(&headers, &state)?;
    let upstream = state
        .database
        .repository(&source)
        .map_err(|_| ApiError::not_found(format!("unknown repository {source}")))?;
    can_read(&identity, &state, &upstream)?;
    let all = forks::forks_of(&state.database, &source).map_err(ApiError::internal)?;
    // Only the forks the caller may see.
    let mut visible = Vec::new();
    for fork in all {
        if can_read(&identity, &state, &fork).is_ok() {
            visible.push(fork);
        }
    }
    Ok(ok(visible))
}

pub(super) async fn sync(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<SyncOutcome>>, ApiError> {
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Write)?;
    let now = unix_time()? as i64;
    let database = state.database.clone();
    let root = state.hosted_root.clone();
    let outcome = state
        .blocking_git
        .run(move || forks::sync(&database, &root, &repository, now).map_err(ApiError::bad))
        .await?;
    Ok(ok(outcome))
}

#[derive(Deserialize)]
pub(super) struct MirrorRequest {
    name: String,
    url: String,
    #[serde(default = "default_branch")]
    branch: String,
}

fn default_branch() -> String {
    "main".into()
}

/// Mirrors are created by forge admins (they fetch from arbitrary URLs).
pub(super) async fn create_mirror(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<MirrorRequest>,
) -> Result<(StatusCode, Json<Envelope<Repository>>), ApiError> {
    let identity = identify(&headers, &state)?;
    if !matches!(
        identity,
        Identity::Master | Identity::Session { admin: true, .. }
    ) {
        return Err(ApiError::forbidden("only forge admins create mirrors"));
    }
    let database = state.database.clone();
    let root = state.hosted_root.clone();
    let mirrored = state
        .blocking_git
        .run(move || {
            forks::mirror(&database, &root, &body.name, &body.url, &body.branch).map_err(|error| {
                if error.to_string().contains("already exists") {
                    ApiError::conflict(error.to_string())
                } else {
                    ApiError::bad(error)
                }
            })
        })
        .await?;
    Ok((StatusCode::CREATED, ok(mirrored)))
}

/// Form posts from the repository page (OLI-276): fork under the given name,
/// or pull from the upstream, then back to the repository.
pub(super) async fn page_fork(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(source): Path<String>,
    request: axum::extract::Request,
) -> Result<axum::response::Response, ApiError> {
    use axum::response::{IntoResponse, Redirect};
    if !super::valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let body = axum::body::to_bytes(request.into_body(), 4096)
        .await
        .map_err(ApiError::bad)?;
    let fields = super::ssh_api::form_fields(&String::from_utf8_lossy(&body));
    let name = fields.get("name").cloned().unwrap_or_default();
    let (_, Json(created)) = fork(
        State(state),
        headers,
        Path(source),
        Json(ForkRequest { name }),
    )
    .await?;
    Ok(Redirect::to(&format!(
        "/app/repositories/{}",
        super::encode_code_segment(&created.data.name)
    ))
    .into_response())
}

pub(super) async fn page_sync(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<axum::response::Response, ApiError> {
    use axum::response::{IntoResponse, Redirect};
    if !super::valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let back = format!(
        "/app/repositories/{}",
        super::encode_code_segment(&repository)
    );
    let _ = sync(State(state), headers, Path(repository)).await?;
    Ok(Redirect::to(&back).into_response())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::{Body, to_bytes},
        http::{Request, header},
        routing::{get, post},
    };
    use std::process::Command;
    use tower::ServiceExt;

    fn git(dir: &std::path::Path, args: &[&str]) {
        let status = Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .expect("git");
        assert!(status.success(), "git {args:?}");
    }

    fn app(state: AppState) -> Router {
        Router::new()
            .route("/api/repos/{repository}/fork", post(fork))
            .route("/api/repos/{repository}/forks", get(list_forks))
            .route("/api/repos/{repository}/sync", post(sync))
            .route("/api/mirrors", post(create_mirror))
            .with_state(state)
    }

    async fn send(
        app: &Router,
        method: &str,
        uri: &str,
        auth: Option<&str>,
        body: serde_json::Value,
    ) -> (StatusCode, serde_json::Value) {
        let mut request = Request::builder()
            .method(method)
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/json");
        if let Some(auth) = auth {
            request = request.header(header::AUTHORIZATION, auth);
        }
        let response = app
            .clone()
            .oneshot(request.body(Body::from(body.to_string())).expect("request"))
            .await
            .expect("response");
        let status = response.status();
        let bytes = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        (
            status,
            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
        )
    }

    #[tokio::test]
    async fn forking_needs_read_gives_the_forker_admin_and_sync_fast_forwards() {
        let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        let root = state.hosted_root.clone();
        let work = root.join("work");
        std::fs::create_dir_all(&work).expect("work");
        git(&work, &["init", "-q", "-b", "main"]);
        git(&work, &["config", "user.name", "Ada"]);
        git(&work, &["config", "user.email", "ada@example"]);
        std::fs::write(work.join("README.md"), "one\n").expect("write");
        git(&work, &["add", "."]);
        git(&work, &["commit", "-q", "-m", "first"]);
        let hosted = crate::hosted_git::host(&root, "upstream", &work).expect("host");
        state
            .database
            .add_repository("upstream", hosted.to_str().expect("utf8"), "main")
            .expect("add");
        let now = unix_time().ok().expect("now") as i64;
        let (_, bob) = access::create_token(
            &state.database,
            &access::NewToken {
                kind: access::Kind::Pat,
                name: "t",
                subject: Some("bob@example"),
                repository: None,
                role: Role::Admin,
                ttl_secs: None,
            },
            now,
        )
        .expect("pat");
        let bob_bearer = format!("Bearer {bob}");
        let app = app(state.clone());

        // private upstream, bob has no role: refused
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/upstream/fork",
            Some(&bob_bearer),
            serde_json::json!({"name": "bob-fork"}),
        )
        .await;
        assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
        // public upstream: anyone signed in may fork and becomes admin of the fork
        state
            .database
            .set_repository_visibility("upstream", "public")
            .expect("public");
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/upstream/fork",
            Some(&bob_bearer),
            serde_json::json!({"name": "bob-fork"}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "{body}");
        assert_eq!(body["data"]["fork_of"], "upstream");
        assert_eq!(
            access::collaborator_role(&state.database, "bob-fork", "bob@example").expect("role"),
            Some(Role::Admin)
        );
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/upstream/fork",
            Some(&bob_bearer),
            serde_json::json!({"name": "bob-fork"}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT, "{body}");
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/upstream/fork",
            Some(&bob_bearer),
            serde_json::json!({"name": "../bad"}),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");

        // forks list: the private fork is visible to bob (admin) and the controller, not to a stranger
        let (status, body) = send(
            &app,
            "GET",
            "/api/repos/upstream/forks",
            Some("Bearer api-token-123"),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body["data"].as_array().map(Vec::len), Some(1), "{body}");
        let (_, eve) = access::create_token(
            &state.database,
            &access::NewToken {
                kind: access::Kind::Pat,
                name: "e",
                subject: Some("eve@example"),
                repository: None,
                role: Role::Admin,
                ttl_secs: None,
            },
            now,
        )
        .expect("pat");
        let (status, body) = send(
            &app,
            "GET",
            "/api/repos/upstream/forks",
            Some(&format!("Bearer {eve}")),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body["data"].as_array().map(Vec::len), Some(0), "{body}");

        // sync needs write on the fork; bob has admin
        std::fs::write(work.join("b.txt"), "two\n").expect("write");
        git(&work, &["add", "."]);
        git(&work, &["commit", "-q", "-m", "second"]);
        crate::hosted_git::sync(&root, "upstream", &work).expect("publish");
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/bob-fork/sync",
            Some(&format!("Bearer {eve}")),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/bob-fork/sync",
            Some(&bob_bearer),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{body}");
        assert_eq!(body["data"]["updated"], true, "{body}");
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/upstream/sync",
            Some("Bearer api-token-123"),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST, "not a fork: {body}");

        // mirrors: admins only, from a path or URL
        let source = work.to_str().expect("utf8");
        let (status, body) = send(
            &app,
            "POST",
            "/api/mirrors",
            Some(&bob_bearer),
            serde_json::json!({"name": "m", "url": source}),
        )
        .await;
        assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
        let (status, body) = send(
            &app,
            "POST",
            "/api/mirrors",
            Some("Bearer api-token-123"),
            serde_json::json!({"name": "m", "url": source}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "{body}");
        assert_eq!(body["data"]["mirror_of"], source);
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/m/sync",
            Some("Bearer api-token-123"),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{body}");
        assert_eq!(body["data"]["updated"], false, "{body}");
    }
}