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}");
}
}