From b64fc22c185291a5fc2d7396b88207bd62e89244 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Mon, 20 Nov 2023 10:47:25 +0100 Subject: [PATCH] httpd: Show private projects in project listing We check if the `SocketAddr` from the request is equal to `127.0.0.1` and if so we also list private repos. --- radicle-httpd/src/api.rs | 4 +- radicle-httpd/src/api/v1/delegates.rs | 117 +++++++++++++++++++++----- radicle-httpd/src/api/v1/projects.rs | 108 ++++++++++++++++++++---- radicle-httpd/src/test.rs | 40 +++++++++ 4 files changed, 234 insertions(+), 35 deletions(-) diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs index 719ecc75..797946fc 100644 --- a/radicle-httpd/src/api.rs +++ b/radicle-httpd/src/api.rs @@ -66,6 +66,7 @@ impl Context { Ok(project::Info { payload, delegates, + visibility: doc.visibility, head, issues, patches, @@ -185,7 +186,7 @@ mod project { use radicle::cob; use radicle::git::Oid; use radicle::identity::project::Project; - use radicle::identity::Id; + use radicle::identity::{Id, Visibility}; use radicle::prelude::Did; /// Project info. @@ -196,6 +197,7 @@ mod project { #[serde(flatten)] pub payload: Project, pub delegates: NonEmpty, + pub visibility: Visibility, pub head: Oid, pub patches: cob::patch::PatchCounts, pub issues: cob::issue::IssueCounts, diff --git a/radicle-httpd/src/api/v1/delegates.rs b/radicle-httpd/src/api/v1/delegates.rs index 375af9c4..33fc38dd 100644 --- a/radicle-httpd/src/api/v1/delegates.rs +++ b/radicle-httpd/src/api/v1/delegates.rs @@ -1,11 +1,13 @@ -use axum::extract::State; +use std::net::SocketAddr; + +use axum::extract::{ConnectInfo, State}; use axum::response::IntoResponse; use axum::routing::get; use axum::{Json, Router}; use radicle::cob::issue::Issues; use radicle::cob::patch::Patches; -use radicle::identity::{Did, DocAt}; +use radicle::identity::{Did, Visibility}; use radicle::node::routing::Store; use radicle::storage::{ReadRepository, ReadStorage}; @@ -27,6 +29,7 @@ pub fn router(ctx: Context) -> Router { /// List all projects which delegate is a part of. /// `GET /delegates/:delegate/projects` async fn delegates_projects_handler( + ConnectInfo(addr): ConnectInfo, State(ctx): State, Path(delegate): Path, Query(qs): Query, @@ -36,27 +39,31 @@ async fn delegates_projects_handler( let per_page = per_page.unwrap_or(10); let storage = &ctx.profile.storage; let routing = &ctx.profile.routing()?; - let projects = storage - .inventory()? + let mut projects = storage + .repositories()? + .into_iter() + .filter(|id| match &id.doc.visibility { + Visibility::Private { .. } => addr.ip().is_loopback(), + Visibility::Public => true, + }) + .collect::>(); + projects.sort_by_key(|p| p.rid); + + let infos = projects .into_iter() .filter_map(|id| { - let Ok(repo) = storage.repository(id) else { + if !id.doc.delegates.iter().any(|d| *d == delegate) { + return None; + } + let Ok(repo) = storage.repository(id.rid) else { return None; }; let Ok((_, head)) = repo.head() else { return None; }; - let Ok(DocAt { doc, .. }) = repo.identity_doc() else { + let Ok(payload) = id.doc.project() else { return None; }; - let Ok(payload) = doc.project() else { - return None; - }; - - if !doc.delegates.iter().any(|d| *d == delegate) { - return None; - } - let Ok(issues) = Issues::open(&repo) else { return None; }; @@ -70,16 +77,17 @@ async fn delegates_projects_handler( return None; }; - let delegates = doc.delegates; - let trackings = routing.count(&id).unwrap_or_default(); + let delegates = id.doc.delegates; + let trackings = routing.count(&id.rid).unwrap_or_default(); Some(Info { payload, delegates, + visibility: id.doc.visibility, head, issues, patches, - id, + id: id.rid, trackings, }) }) @@ -87,11 +95,14 @@ async fn delegates_projects_handler( .take(per_page) .collect::>(); - Ok::<_, Error>(Json(projects)) + Ok::<_, Error>(Json(infos)) } #[cfg(test)] mod routes { + use std::net::SocketAddr; + + use axum::extract::connect_info::MockConnectInfo; use axum::http::StatusCode; use serde_json::json; @@ -100,7 +111,72 @@ mod routes { #[tokio::test] async fn test_delegates_projects() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let seed = test::seed(tmp.path()); + let app = super::router(seed.clone()) + .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080)))); + let response = get( + &app, + "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects", + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "name": "hello-world-private", + "description": "Private Rad repository for tests", + "defaultBranch": "master", + "delegates": [ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + ], + "visibility": { + "type": "private", + }, + "head": "d26ed310ed140fbef2a066aa486cf59a0f9f7812", + "patches": { + "open": 0, + "draft": 0, + "archived": 0, + "merged": 0, + }, + "issues": { + "open": 0, + "closed": 0, + }, + "id": "rad:zLuTzcmoWMcdK37xqArS8eckp9vK", + "trackings": 0, + }, + { + "name": "hello-world", + "description": "Rad repository for tests", + "defaultBranch": "master", + "delegates": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"], + "visibility": { + "type": "public" + }, + "head": HEAD, + "patches": { + "open": 1, + "draft": 0, + "archived": 0, + "merged": 0, + }, + "issues": { + "open": 1, + "closed": 0, + }, + "id": RID, + "trackings": 0, + }, + ]) + ); + + let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from(( + [192, 168, 13, 37], + 8080, + )))); let response = get( &app, "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects", @@ -116,6 +192,9 @@ mod routes { "description": "Rad repository for tests", "defaultBranch": "master", "delegates": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"], + "visibility": { + "type": "public" + }, "head": HEAD, "patches": { "open": 1, diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index a7fe11e7..b45e837c 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; +use std::net::SocketAddr; -use axum::extract::{DefaultBodyLimit, State}; +use axum::extract::{ConnectInfo, DefaultBodyLimit, State}; use axum::handler::Handler; use axum::http::{header, HeaderValue}; use axum::response::IntoResponse; @@ -14,7 +15,7 @@ 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::identity::{Did, Id, Visibility}; use radicle::node::routing::Store; use radicle::node::AliasStore; use radicle::node::NodeId; @@ -75,6 +76,7 @@ pub fn router(ctx: Context) -> Router { /// List all projects. /// `GET /projects` async fn project_root_handler( + ConnectInfo(addr): ConnectInfo, State(ctx): State, Query(qs): Query, ) -> impl IntoResponse { @@ -83,21 +85,26 @@ async fn project_root_handler( let per_page = per_page.unwrap_or(10); let storage = &ctx.profile.storage; let routing = &ctx.profile.routing()?; - let projects = storage - .inventory()? + let mut projects = storage + .repositories()? + .into_iter() + .filter(|id| match &id.doc.visibility { + Visibility::Private { .. } => addr.ip().is_loopback(), + Visibility::Public => true, + }) + .collect::>(); + projects.sort_by_key(|p| p.rid); + + let infos = projects .into_iter() .filter_map(|id| { - let Ok(repo) = storage.repository(id) else { + let Ok(repo) = storage.repository(id.rid) 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 { + let Ok(payload) = id.doc.project() else { return None; }; let Ok(issues) = issue::Issues::open(&repo) else { @@ -112,16 +119,17 @@ async fn project_root_handler( let Ok(patches) = patches.counts() else { return None; }; - let delegates = doc.delegates; - let trackings = routing.count(&id).unwrap_or_default(); + let delegates = id.doc.delegates; + let trackings = routing.count(&id.rid).unwrap_or_default(); Some(Info { payload, delegates, head, + visibility: id.doc.visibility, issues, patches, - id, + id: id.rid, trackings, }) }) @@ -129,7 +137,7 @@ async fn project_root_handler( .take(per_page) .collect::>(); - Ok::<_, Error>(Json(projects)) + Ok::<_, Error>(Json(infos)) } /// Get project metadata. @@ -990,7 +998,10 @@ async fn patch_handler( #[cfg(test)] mod routes { + use std::net::SocketAddr; + use axum::body::Body; + use axum::extract::connect_info::MockConnectInfo; use axum::http::StatusCode; use pretty_assertions::assert_eq; use serde_json::json; @@ -1000,7 +1011,68 @@ mod routes { #[tokio::test] async fn test_projects_root() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(seed(tmp.path())); + let seed = seed(tmp.path()); + let app = super::router(seed.clone()) + .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080)))); + let response = get(&app, "/projects").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "name": "hello-world-private", + "description": "Private Rad repository for tests", + "defaultBranch": "master", + "delegates": [ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + ], + "visibility": { + "type": "private", + }, + "head": "d26ed310ed140fbef2a066aa486cf59a0f9f7812", + "patches": { + "open": 0, + "draft": 0, + "archived": 0, + "merged": 0, + }, + "issues": { + "open": 0, + "closed": 0, + }, + "id": "rad:zLuTzcmoWMcdK37xqArS8eckp9vK", + "trackings": 0, + }, + { + "name": "hello-world", + "description": "Rad repository for tests", + "defaultBranch": "master", + "delegates": [DID], + "visibility": { + "type": "public" + }, + "head": HEAD, + "patches": { + "open": 1, + "draft": 0, + "archived": 0, + "merged": 0, + }, + "issues": { + "open": 1, + "closed": 0, + }, + "id": RID, + "trackings": 0, + }, + ]) + ); + + let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from(( + [192, 168, 13, 37], + 8080, + )))); let response = get(&app, "/projects").await; assert_eq!(response.status(), StatusCode::OK); @@ -1012,6 +1084,9 @@ mod routes { "description": "Rad repository for tests", "defaultBranch": "master", "delegates": [DID], + "visibility": { + "type": "public" + }, "head": HEAD, "patches": { "open": 1, @@ -1044,6 +1119,9 @@ mod routes { "description": "Rad repository for tests", "defaultBranch": "master", "delegates": [DID], + "visibility": { + "type": "public" + }, "head": HEAD, "patches": { "open": 1, diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index 0c4c5499..e1001be7 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::path::Path; use std::str::FromStr; use std::sync::Arc; @@ -107,6 +108,45 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G RoutingStore::Table::open(routing_db).unwrap(); AddressStore::Book::open(addresses_db).unwrap(); + let workdir = dir.join("hello-world-private"); + fs::create_dir_all(&workdir).unwrap(); + + // add commits to workdir (repo) + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head(DEFAULT_BRANCH); + let repo = git2::Repository::init_opts(&workdir, &opts).unwrap(); + let tree = radicle::git::write_tree( + Path::new("README"), + "Hello Private World!\n".as_bytes(), + &repo, + ) + .unwrap(); + + let sig_time = git2::Time::new(1673001014, 0); + let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap(); + + repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[]) + .unwrap(); + + // rad init + let repo = git2::Repository::open(&workdir).unwrap(); + let name = "hello-world-private".to_string(); + let description = "Private Rad repository for tests".to_string(); + let branch = RefString::try_from(DEFAULT_BRANCH).unwrap(); + let visibility = Visibility::Private { + allow: BTreeSet::default(), + }; + radicle::rad::init( + &repo, + &name, + &description, + branch, + visibility, + signer, + &profile.storage, + ) + .unwrap(); + let workdir = dir.join("hello-world"); env::set_var("RAD_COMMIT_TIME", TIMESTAMP.to_string());