use std::collections::{BTreeMap, HashMap}; use axum::extract::{DefaultBodyLimit, State}; use axum::handler::Handler; use axum::http::{header, HeaderValue}; use axum::response::IntoResponse; use axum::routing::{get, patch, post}; use axum::{Json, Router}; use axum_auth::AuthBearer; use hyper::StatusCode; use radicle_surf::blob::{Blob, BlobRef}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tower_http::set_header::SetResponseHeaderLayer; use radicle::cob::{issue, patch, Embed, Label, Uri}; use radicle::identity::{Did, DocAt, Id}; use radicle::node::routing::Store; use radicle::node::AliasStore; use radicle::node::NodeId; use radicle::storage::git::paths; use radicle::storage::{ReadRepository, ReadStorage, WriteRepository}; use radicle_surf::{diff, Glob, Oid, Repository}; use crate::api::error::Error; use crate::api::project::Info; use crate::api::{self, CobsQuery, Context, DataUri, PaginationQuery}; use crate::axum_extra::{Path, Query}; const CACHE_1_HOUR: &str = "public, max-age=3600, must-revalidate"; const MAX_BODY_LIMIT: usize = 4_194_304; pub fn router(ctx: Context) -> Router { Router::new() .route("/projects", get(project_root_handler)) .route("/projects/:project", get(project_handler)) .route("/projects/:project/commits", get(history_handler)) .route("/projects/:project/commits/:sha", get(commit_handler)) .route("/projects/:project/diff/:base/:oid", get(diff_handler)) .route( "/projects/:project/activity", get( activity_handler.layer(SetResponseHeaderLayer::if_not_present( header::CACHE_CONTROL, HeaderValue::from_static(CACHE_1_HOUR), )), ), ) .route("/projects/:project/tree/:sha/", get(tree_handler_root)) .route("/projects/:project/tree/:sha/*path", get(tree_handler)) .route("/projects/:project/remotes", get(remotes_handler)) .route("/projects/:project/remotes/:peer", get(remote_handler)) .route("/projects/:project/blob/:sha/*path", get(blob_handler)) .route("/projects/:project/readme/:sha", get(readme_handler)) .route( "/projects/:project/issues", post(issue_create_handler).get(issues_handler), ) .route( "/projects/:project/issues/:id", patch(issue_update_handler).get(issue_handler), ) .route( "/projects/:project/patches", post(patch_create_handler).get(patches_handler), ) .route( "/projects/:project/patches/:id", patch(patch_update_handler).get(patch_handler), ) .with_state(ctx) .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT)) } /// List all projects. /// `GET /projects` async fn project_root_handler( State(ctx): State, Query(qs): Query, ) -> impl IntoResponse { let PaginationQuery { page, per_page } = qs; let page = page.unwrap_or(0); let per_page = per_page.unwrap_or(10); let storage = &ctx.profile.storage; let routing = &ctx.profile.routing()?; let projects = storage .inventory()? .into_iter() .filter_map(|id| { let Ok(repo) = storage.repository(id) else { return None }; let Ok((_, head)) = repo.head() else { return None }; let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None }; let Ok(payload) = doc.project() else { return None }; let Ok(issues) = issue::Issues::open(&repo) else { return None }; let Ok(issues) = issues.counts() else { return None }; let Ok(patches) = patch::Patches::open(&repo) else { return None }; let Ok(patches) = patches.counts() else { return None }; let delegates = doc.delegates; let trackings = routing.count(&id).unwrap_or_default(); Some(Info { payload, delegates, head, issues, patches, id, trackings, }) }) .skip(page * per_page) .take(per_page) .collect::>(); Ok::<_, Error>(Json(projects)) } /// Get project metadata. /// `GET /projects/:project` async fn project_handler(State(ctx): State, Path(id): Path) -> impl IntoResponse { let info = ctx.project_info(id)?; Ok::<_, Error>(Json(info)) } #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct CommitsQueryString { pub parent: Option, pub since: Option, pub until: Option, pub page: Option, pub per_page: Option, } /// Get project commit range. /// `GET /projects/:project/commits?since=` async fn history_handler( State(ctx): State, Path(project): Path, Query(qs): Query, ) -> impl IntoResponse { let CommitsQueryString { since, until, parent, page, per_page, } = qs; let sha = match parent { Some(commit) => commit, None => { let info = ctx.project_info(project)?; info.head.to_string() } }; let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page. let page = page.unwrap_or(0); let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) { usize::MAX } else { per_page.unwrap_or(30) }; let commits = repo .history(&sha)? .filter(|q| { if let Ok(q) = q { if let (Some(since), Some(until)) = (since, until) { q.committer.time.seconds() >= since && q.committer.time.seconds() < until } else if let Some(since) = since { q.committer.time.seconds() >= since } else if let Some(until) = until { q.committer.time.seconds() < until } else { // If neither `since` nor `until` are specified, we include the commit. true } } else { false } }) .skip(page * per_page) .take(per_page) .map(|r| { r.and_then(|c| { let glob = Glob::all_heads().branches().and(Glob::all_remotes()); let branches: Vec = repo .revision_branches(c.id, glob)? .iter() .map(|b| b.refname().to_string()) .collect(); let diff = repo.diff_commit(c.id)?; Ok(json!({ "commit": api::json::commit(&c), "diff": diff, "branches": branches })) }) }) .collect::, _>>()?; let response = json!({ "commits": commits, "stats": repo.stats_from(&sha)?, }); Ok::<_, Error>((StatusCode::OK, Json(response))) } /// Get project commit. /// `GET /projects/:project/commits/:sha` async fn commit_handler( State(ctx): State, Path((project, sha)): Path<(Id, Oid)>, ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let commit = repo.commit(sha)?; let diff = repo.diff_commit(commit.id)?; let glob = Glob::all_heads().branches().and(Glob::all_remotes()); let branches: Vec = repo .revision_branches(commit.id, glob)? .iter() .map(|b| b.refname().to_string()) .collect(); let mut files: HashMap = HashMap::new(); diff.files().for_each(|file_diff| match file_diff { diff::FileDiff::Added(added) => { if let Ok(blob) = repo.blob(commit.id, &added.path) { files.insert( blob.object_id(), json!({ "binary": blob.is_binary(), "content": api::json::blob_content(&blob) }), ); } } diff::FileDiff::Deleted(deleted) => { commit .parents .iter() .filter_map(|oid| repo.blob(oid, &deleted.path).ok()) .for_each(|blob| { files.insert( blob.object_id(), json!({ "binary": blob.is_binary(), "content": api::json::blob_content(&blob) }), ); }); } diff::FileDiff::Modified(modified) => { if let Ok(new_blob) = repo.blob(commit.id, &modified.path) { files.insert( new_blob.object_id(), json!({ "binary": new_blob.is_binary(), "content": api::json::blob_content(&new_blob) }), ); } commit .parents .iter() .filter_map(|oid| repo.blob(oid, &modified.path).ok()) .for_each(|blob| { files.insert( blob.object_id(), json!({ "binary": blob.is_binary(), "content": api::json::blob_content(&blob) }), ); }); } diff::FileDiff::Moved(moved) => { if let (Ok(old_blob), Ok(new_blob)) = ( repo.blob(moved.old.oid, &moved.old_path), repo.blob(moved.new.oid, &moved.new_path), ) { files.insert( old_blob.object_id(), json!({ "binary": old_blob.is_binary(), "content": api::json::blob_content(&old_blob) }), ); files.insert( new_blob.object_id(), json!({ "binary": new_blob.is_binary(), "content": api::json::blob_content(&new_blob) }), ); } } diff::FileDiff::Copied(copied) => { if let (Ok(old_blob), Ok(new_blob)) = ( repo.blob(copied.old.oid, &copied.old_path), repo.blob(copied.new.oid, &copied.new_path), ) { files.insert( old_blob.object_id(), json!({ "binary": old_blob.is_binary(), "content": api::json::blob_content(&old_blob) }), ); files.insert( new_blob.object_id(), json!({ "binary": new_blob.is_binary(), "content": api::json::blob_content(&new_blob) }), ); } } }); let response = json!({ "commit": api::json::commit(&commit), "diff": diff, "files": files, "branches": branches }); Ok::<_, Error>(Json(response)) } /// Get diff between two commits /// `GET /projects/:project/diff/:base/:oid` async fn diff_handler( State(ctx): State, Path((project, base, oid)): Path<(Id, Oid, Oid)>, ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let base = repo.commit(base)?; let commit = repo.commit(oid)?; let diff = repo.diff(base.id, commit.id)?; let mut files: HashMap>> = HashMap::new(); diff.files().for_each(|file_diff| match file_diff { diff::FileDiff::Added(added) => { if let Ok(blob) = repo.blob(commit.id, &added.path) { files.insert(blob.object_id(), blob); } } diff::FileDiff::Deleted(deleted) => { if let Ok(old_blob) = repo.blob(base.id, &deleted.path) { files.insert(old_blob.object_id(), old_blob); } } diff::FileDiff::Modified(modified) => { if let (Ok(new_blob), Ok(old_blob)) = ( repo.blob(commit.id, &modified.path), repo.blob(base.id, &modified.path), ) { files.insert(new_blob.object_id(), new_blob); files.insert(old_blob.object_id(), old_blob); } } diff::FileDiff::Moved(moved) => { if let (Ok(new_blob), Ok(old_blob)) = ( repo.blob(moved.new.oid, &moved.new_path), repo.blob(moved.old.oid, &moved.old_path), ) { files.insert(new_blob.object_id(), new_blob); files.insert(old_blob.object_id(), old_blob); } } diff::FileDiff::Copied(copied) => { if let (Ok(new_blob), Ok(old_blob)) = ( repo.blob(copied.new.oid, &copied.new_path), repo.blob(copied.old.oid, &copied.old_path), ) { files.insert(new_blob.object_id(), new_blob); files.insert(old_blob.object_id(), old_blob); } } }); let commits = repo .history(commit.id)? .take_while(|c| { if let Ok(c) = c { c.id != base.id } else { false } }) .map(|r| r.map(|c| api::json::commit(&c))) .collect::, _>>()?; let response = json!({ "diff": diff, "files": files, "commits": commits }); Ok::<_, Error>(Json(response)) } /// Get project activity for the past year. /// `GET /projects/:project/activity` async fn activity_handler( State(ctx): State, Path(project): Path, ) -> impl IntoResponse { let current_date = chrono::Utc::now().timestamp(); let one_year_ago = chrono::Duration::weeks(52); let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let head = repo.head()?; let timestamps = repo .history(head)? .filter_map(|a| { if let Ok(a) = a { let seconds = a.committer.time.seconds(); if seconds > current_date - one_year_ago.num_seconds() { return Some(seconds); } } None }) .collect::>(); Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps })))) } /// Get project source tree for '/' path. /// `GET /projects/:project/tree/:sha/` async fn tree_handler_root( State(ctx): State, Path((project, sha)): Path<(Id, Oid)>, ) -> impl IntoResponse { tree_handler(State(ctx), Path((project, sha, String::new()))).await } /// Get project source tree. /// `GET /projects/:project/tree/:sha/*path` async fn tree_handler( State(ctx): State, Path((project, sha, path)): Path<(Id, Oid, String)>, ) -> impl IntoResponse { if let Some(ref cache) = ctx.cache { let cache = &mut cache.tree.lock().await; if let Some(response) = cache.get(&(project, sha, path.clone())) { return Ok::<_, Error>(Json(response.clone())); } } let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let tree = repo.tree(sha, &path)?; let stats = repo.stats_from(&sha)?; let response = api::json::tree(&tree, &path, &stats); if let Some(cache) = ctx.cache { let cache = &mut cache.tree.lock().await; cache.put((project, sha, path.clone()), response.clone()); } Ok::<_, Error>(Json(response)) } /// Get all project remotes. /// `GET /projects/:project/remotes` async fn remotes_handler(State(ctx): State, Path(project): Path) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = storage.repository(project)?; let delegates = repo.delegates()?; let aliases = &ctx.profile.aliases(); let remotes = repo .remotes()? .filter_map(|r| r.map(|r| r.1).ok()) .map(|remote| { let refs = remote .refs .iter() .filter_map(|(r, oid)| { r.as_str() .strip_prefix("refs/heads/") .map(|head| (head.to_string(), oid)) }) .collect::>(); match aliases.alias(&remote.id) { Some(alias) => json!({ "id": remote.id, "alias": alias, "heads": refs, "delegate": delegates.contains(&remote.id.into()), }), None => json!({ "id": remote.id, "heads": refs, "delegate": delegates.contains(&remote.id.into()), }), } }) .collect::>(); Ok::<_, Error>(Json(remotes)) } /// Get project remote. /// `GET /projects/:project/remotes/:peer` async fn remote_handler( State(ctx): State, Path((project, node_id)): Path<(Id, NodeId)>, ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = storage.repository(project)?; let delegates = repo.delegates()?; let remote = repo.remote(&node_id)?; let refs = remote .refs .iter() .filter_map(|(r, oid)| { r.as_str() .strip_prefix("refs/heads/") .map(|head| (head.to_string(), oid)) }) .collect::>(); let remote = json!({ "id": remote.id, "heads": refs, "delegate": delegates.contains(&remote.id.into()), }); Ok::<_, Error>(Json(remote)) } /// Get project source file. /// `GET /projects/:project/blob/:sha/*path` async fn blob_handler( State(ctx): State, Path((project, sha, path)): Path<(Id, Oid, String)>, ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let blob = repo.blob(sha, &path)?; let response = api::json::blob(&blob, &path); Ok::<_, Error>(Json(response)) } /// Get project readme. /// `GET /projects/:project/readme/:sha` async fn readme_handler( State(ctx): State, Path((project, sha)): Path<(Id, Oid)>, ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = Repository::open(paths::repository(storage, &project))?; let paths = [ "README", "README.md", "README.markdown", "README.txt", "README.rst", "Readme.md", ]; for path in paths .iter() .map(ToString::to_string) .chain(paths.iter().map(|p| p.to_lowercase())) { if let Ok(blob) = repo.blob(sha, &path) { let response = api::json::blob(&blob, &path); return Ok::<_, Error>(Json(response)); } } Err(Error::NotFound) } /// Get project issues list. /// `GET /projects/:project/issues` async fn issues_handler( State(ctx): State, Path(project): Path, Query(qs): Query>, ) -> impl IntoResponse { let CobsQuery { page, per_page, state, } = qs; let page = page.unwrap_or(0); let per_page = per_page.unwrap_or(10); let state = state.unwrap_or_default(); let storage = &ctx.profile.storage; let repo = storage.repository(project)?; let issues = issue::Issues::open(&repo)?; let mut issues: Vec<_> = issues .all()? .filter_map(|r| { let (id, issue) = r.ok()?; (state.matches(issue.state())).then_some((id, issue)) }) .collect::>(); issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp())); let aliases = &ctx.profile.aliases(); let issues = issues .into_iter() .map(|(id, issue)| api::json::issue(id, issue, aliases)) .skip(page * per_page) .take(per_page) .collect::>(); Ok::<_, Error>(Json(issues)) } #[derive(Debug, Deserialize, Serialize)] pub struct IssueCreate { pub title: String, pub description: String, pub labels: Vec