From 59dbbf0f2986e8d969cc30b57d70f76984a272e3 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 1 Dec 2025 23:47:27 +0000 Subject: add repo land page and 404 page per GRASP-01 --- .../src/specs/grasp01/repository_creation.rs | 193 ++++++++++ src/http/landing.rs | 407 +++++++++++++++++++++ src/http/mod.rs | 106 +++++- tests/repository_creation.rs | 2 + 4 files changed, 701 insertions(+), 7 deletions(-) diff --git a/grasp-audit/src/specs/grasp01/repository_creation.rs b/grasp-audit/src/specs/grasp01/repository_creation.rs index 0b3eed5..1014aa3 100644 --- a/grasp-audit/src/specs/grasp01/repository_creation.rs +++ b/grasp-audit/src/specs/grasp01/repository_creation.rs @@ -27,6 +27,8 @@ impl RepositoryCreationTests { let mut results = crate::AuditResult::new("GRASP-01 Repository Creation Tests"); results.add(Self::test_bare_repo_created_on_announcement(client, relay_domain).await); + results.add(Self::test_webpage_served_for_existing_repo(client, relay_domain).await); + results.add(Self::test_404_for_nonexistent_repo(client, relay_domain).await); results } @@ -109,6 +111,144 @@ impl RepositoryCreationTests { ) .pass() } + + /// Test that a webpage is served for an existing repository + /// + /// This test verifies: + /// 1. Creates a valid repository announcement + /// 2. Accesses the repository URL without git service parameters + /// 3. Verifies a webpage is returned (any 2xx status with HTML content) + /// + /// GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s)" + pub async fn test_webpage_served_for_existing_repo( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_webpage_served_for_existing_repo"; + let ctx = TestContext::new(client); + + // Create a repository announcement + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail(format!("Failed to create repo fixture: {}", e)) + } + }; + + // Wait for repository creation + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo identifier and npub + let repo_id = match repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail("Repository announcement missing d tag") + } + }; + + let npub = match repo.pubkey.to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail(format!("Failed to convert pubkey to npub: {}", e)) + } + }; + + // Check that a webpage is served at the repository URL + if let Err(e) = check_webpage_served(relay_domain, &npub, &repo_id).await { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail(format!("Webpage not served: {}", e)); + } + + TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .pass() + } + + /// Test that 404 is returned for non-existent repositories + /// + /// This test verifies: + /// 1. Accesses a URL for a repository that doesn't exist + /// 2. Verifies a 404 status is returned + /// + /// GRASP-01: "...and a 404 page for repositories it doesn't host" + pub async fn test_404_for_nonexistent_repo( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_404_for_nonexistent_repo"; + + let ctx = TestContext::new(client); + + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail(format!("Failed to create repo fixture: {}", e)) + } + }; + + let npub = match repo.pubkey.to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD serve a webpage for existing repositories", + ) + .fail(format!("Failed to convert pubkey to npub: {}", e)) + } + }; + // Use a clearly non-existent repo id but real npub + let fake_repo_id = "nonexistent-repo-12345"; + + // Check that 404 is returned + if let Err(e) = check_404_for_nonexistent_repo(relay_domain, &npub, fake_repo_id).await { + return TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD return 404 for repositories it doesn't host", + ) + .fail(format!("Expected 404, got: {}", e)); + } + + TestResult::new( + test_name, + "GRASP-01", + "Relay SHOULD return 404 for repositories it doesn't host", + ) + .pass() + } } /// Helper function to check if a repository is accessible via Smart HTTP service @@ -156,3 +296,56 @@ async fn check_repo_accessible_via_http( Ok(()) } + +/// Helper function to check if a webpage is served for an existing repository +/// +/// Verifies that accessing the repository URL returns a webpage (2xx status) +/// URL format: http://domain/npub/identifier.git +async fn check_webpage_served(relay_domain: &str, npub: &str, repo_id: &str) -> Result<(), String> { + let repo_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&repo_url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Expected 2xx status for existing repo webpage, got {} for URL: {}", + response.status(), + repo_url + )); + } + + Ok(()) +} + +/// Helper function to check that 404 is returned for non-existent repository +/// +/// Verifies that accessing a non-existent repository URL returns 404 +async fn check_404_for_nonexistent_repo( + relay_domain: &str, + npub: &str, + repo_id: &str, +) -> Result<(), String> { + let repo_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&repo_url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if response.status().as_u16() != 404 { + return Err(format!( + "Expected 404 status for non-existent repo, got {} for URL: {}", + response.status(), + repo_url + )); + } + + Ok(()) +} diff --git a/src/http/landing.rs b/src/http/landing.rs index 55ffb26..ddde09a 100644 --- a/src/http/landing.rs +++ b/src/http/landing.rs @@ -13,3 +13,410 @@ pub fn get_html(config: &Config) -> String { bind_address = config.bind_address, ) } + +/// Generate a generic 404 page for unknown paths +/// +/// Used for any path that doesn't match a known route +pub fn get_generic_404_html(config: &Config, path: &str) -> String { + format!( + r#" + + + + + Not Found - {relay_name} + + + +
+

404

+

Not Found

+

The page you're looking for doesn't exist.

+ +
+

Requested path: {path}

+
+ + + + +
+ +"#, + relay_name = config.relay_name, + path = path, + ) +} + +/// Generate a 404 page for a non-existent repository +/// +/// GRASP-01: "...and a 404 page for repositories it doesn't host" +pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String { + format!( + r#" + + + + + Repository Not Found - {relay_name} + + + +
+

404

+

Repository Not Found

+

The repository you're looking for doesn't exist on this GRASP server.

+ +
+

Owner: {npub}

+

Repository: {identifier}

+
+ +

This repository may not have been announced to this server, or the URL may be incorrect.

+ + + + +
+ +"#, + relay_name = config.relay_name, + npub = npub, + identifier = identifier, + ) +} + +/// Generate a webpage for an existing repository +/// +/// GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) +/// to browse the repository" +pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { + let clone_url = format!( + "http://{}/{}/{}.git", + config.domain, npub, identifier + ); + + format!( + r#" + + + + + {identifier} - {relay_name} + + + +
+ + +

📦 {identifier}

+

Git repository hosted on {relay_name}

+ +

📋 Repository Information

+
+

Owner: {npub}

+

Repository: {identifier}

+
+ +

🔗 Clone this Repository

+
+ git clone {clone_url} +
+ +

🌐 Browse with Git Nostr Clients

+

You can browse this repository using these Git Nostr clients:

+
+
+ gitworkshop.dev - Web-based repository browser + Visit → +
+
+ ngit - Command-line Git + Nostr tool + GitHub → +
+
+ +

📚 About GRASP

+

This repository is hosted using the GRASP (Git Relays Authorized via Signed-Nostr Proofs) protocol.

+ + + +
+ +"#, + relay_name = config.relay_name, + npub = npub, + identifier = identifier, + clone_url = clone_url, + ) +} diff --git a/src/http/mod.rs b/src/http/mod.rs index f43cf86..6da027c 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -32,6 +32,47 @@ const CORS_ALLOW_ORIGIN: &str = "*"; const CORS_ALLOW_METHODS: &str = "GET, POST"; const CORS_ALLOW_HEADERS: &str = "Content-Type"; +/// Extract npub and identifier from a repository URL path (no git subpath required) +/// +/// Parses paths like `//.git` (for repository webpage/404) +/// +/// Returns (npub, identifier) if the path matches a repository URL pattern +fn parse_repo_url(path: &str) -> Option<(&str, &str)> { + // Remove leading slash + let path = path.strip_prefix('/').unwrap_or(path); + + // Split into components + let parts: Vec<&str> = path.split('/').collect(); + + // Must be exactly 2 parts: npub and repo.git (no subpath) + if parts.len() != 2 { + return None; + } + + let npub = parts[0]; + let repo_part = parts[1]; + + // The repo part must end with .git + if !repo_part.ends_with(".git") { + return None; + } + + // Must have an npub that looks valid (starts with npub1) + if !npub.starts_with("npub1") { + return None; + } + + // Extract identifier (remove .git suffix) + let identifier = repo_part.strip_suffix(".git").unwrap_or(repo_part); + + // Identifier must not be empty + if identifier.is_empty() { + return None; + } + + Some((npub, identifier)) +} + /// Add CORS headers to a response builder fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::response::Builder { builder @@ -230,6 +271,45 @@ impl Service> for HttpService { } } + // Check for repository URL pattern (e.g., /npub/repo.git without subpath) + // GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) + // to browse the repository and a 404 page for repositories it doesn't host" + if let Some((npub, identifier)) = parse_repo_url(&path) { + let npub = npub.to_string(); + let identifier = identifier.to_string(); + let config = self.config.clone(); + let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier); + + tracing::debug!( + "Repository URL request: {} (npub={}, id={}, path={:?})", + path, + npub, + identifier, + repo_path + ); + + return Box::pin(async move { + // Check if repository exists + if repo_path.exists() { + // Serve repository webpage + let html = landing::get_repo_html(&config, &npub, &identifier); + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(200) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(html))) + .unwrap()) + } else { + // Serve 404 page for non-existent repository + let html = landing::get_404_html(&config, &npub, &identifier); + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(404) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(html))) + .unwrap()) + } + }); + } + // Check if this is a WebSocket upgrade request if let (Some(c), Some(w)) = ( req.headers().get("connection"), @@ -275,14 +355,26 @@ impl Service> for HttpService { } } - // Serve landing page for HTTP requests - let html = landing::get_html(&self.config); + // Only serve landing page for root path "/", 404 for everything else + let config = self.config.clone(); Box::pin(async move { - Ok(base - .status(200) - .header("content-type", "text/html; charset=utf-8") - .body(Full::new(Bytes::from(html))) - .unwrap()) + if path == "/" { + // Serve landing page for root + let html = landing::get_html(&config); + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(200) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(html))) + .unwrap()) + } else { + // Serve generic 404 for unknown paths + let html = landing::get_generic_404_html(&config, &path); + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(404) + .header("content-type", "text/html; charset=utf-8") + .body(Full::new(Bytes::from(html))) + .unwrap()) + } }) } } diff --git a/tests/repository_creation.rs b/tests/repository_creation.rs index 301203b..352e2cc 100644 --- a/tests/repository_creation.rs +++ b/tests/repository_creation.rs @@ -59,3 +59,5 @@ macro_rules! isolated_test { // Generate isolated tests for all repository creation tests isolated_test!(test_bare_repo_created_on_announcement); +isolated_test!(test_webpage_served_for_existing_repo); +isolated_test!(test_404_for_nonexistent_repo); -- cgit v1.2.3