add repo land page and 404 page per GRASP-01

This commit is contained in:
DanConwayDev
2025-12-01 23:47:27 +00:00
parent e6c056023b
commit 59dbbf0f29
4 changed files with 701 additions and 7 deletions
@@ -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(())
}
+407
View File
@@ -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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not Found - {relay_name}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.container {{
max-width: 600px;
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}}
h1 {{
color: #e74c3c;
margin-bottom: 10px;
font-size: 4em;
}}
h2 {{
color: #333;
margin-bottom: 20px;
font-size: 1.5em;
}}
.path-info {{
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #e74c3c;
}}
code {{
background: #f4f4f4;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #667eea;
font-size: 0.85em;
word-break: break-all;
}}
.back-link {{
margin-top: 20px;
}}
a {{
color: #667eea;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #999;
font-size: 0.9em;
}}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<h2>Not Found</h2>
<p>The page you're looking for doesn't exist.</p>
<div class="path-info">
<p><strong>Requested path:</strong> <code>{path}</code></p>
</div>
<div class="back-link">
<a href="/">← Back to {relay_name}</a>
</div>
<div class="footer">
<p>Powered by <strong>ngit-grasp</strong></p>
</div>
</div>
</body>
</html>"#,
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Repository Not Found - {relay_name}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.container {{
max-width: 600px;
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}}
h1 {{
color: #e74c3c;
margin-bottom: 10px;
font-size: 4em;
}}
h2 {{
color: #333;
margin-bottom: 20px;
font-size: 1.5em;
}}
.repo-info {{
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #e74c3c;
}}
code {{
background: #f4f4f4;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #667eea;
font-size: 0.85em;
word-break: break-all;
}}
.back-link {{
margin-top: 20px;
}}
a {{
color: #667eea;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #999;
font-size: 0.9em;
}}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<h2>Repository Not Found</h2>
<p>The repository you're looking for doesn't exist on this GRASP server.</p>
<div class="repo-info">
<p><strong>Owner:</strong> <code>{npub}</code></p>
<p><strong>Repository:</strong> <code>{identifier}</code></p>
</div>
<p>This repository may not have been announced to this server, or the URL may be incorrect.</p>
<div class="back-link">
<a href="/">← Back to {relay_name}</a>
</div>
<div class="footer">
<p>Powered by <strong>ngit-grasp</strong></p>
</div>
</div>
</body>
</html>"#,
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{identifier} - {relay_name}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.container {{
max-width: 800px;
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}}
h1 {{
color: #667eea;
margin-bottom: 10px;
font-size: 2em;
}}
h2 {{
color: #764ba2;
margin-top: 25px;
margin-bottom: 15px;
font-size: 1.3em;
border-bottom: 2px solid #667eea;
padding-bottom: 8px;
}}
.subtitle {{
color: #666;
margin-bottom: 25px;
font-size: 1em;
}}
.repo-info {{
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
border-left: 4px solid #667eea;
}}
code {{
background: #f4f4f4;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #667eea;
font-size: 0.85em;
word-break: break-all;
}}
.clone-box {{
background: #2d3748;
color: #e2e8f0;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-x: auto;
}}
.clone-box code {{
background: transparent;
color: #68d391;
padding: 0;
}}
ul {{
margin: 15px 0;
padding-left: 25px;
}}
li {{
margin: 10px 0;
}}
a {{
color: #667eea;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.client-list {{
display: grid;
gap: 10px;
margin: 15px 0;
}}
.client-item {{
background: #f9f9f9;
padding: 12px 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.badge {{
display: inline-block;
background: #667eea;
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
text-align: center;
color: #999;
font-size: 0.9em;
}}
.back-link {{
margin-bottom: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="back-link">
<a href="/">← Back to {relay_name}</a>
</div>
<h1>📦 {identifier}</h1>
<p class="subtitle">Git repository hosted on {relay_name}</p>
<h2>📋 Repository Information</h2>
<div class="repo-info">
<p><strong>Owner:</strong> <code>{npub}</code></p>
<p><strong>Repository:</strong> <code>{identifier}</code></p>
</div>
<h2>🔗 Clone this Repository</h2>
<div class="clone-box">
git clone <code>{clone_url}</code>
</div>
<h2>🌐 Browse with Git Nostr Clients</h2>
<p>You can browse this repository using these Git Nostr clients:</p>
<div class="client-list">
<div class="client-item">
<span><strong>gitworkshop.dev</strong> - Web-based repository browser</span>
<a href="https://gitworkshop.dev" target="_blank">Visit →</a>
</div>
<div class="client-item">
<span><strong>ngit</strong> - Command-line Git + Nostr tool</span>
<a href="https://github.com/DanConwayDev/ngit-cli" target="_blank">GitHub →</a>
</div>
</div>
<h2>📚 About GRASP</h2>
<p>This repository is hosted using the <strong>GRASP</strong> (Git Relays Authorized via Signed-Nostr Proofs) protocol.</p>
<ul>
<li><a href="https://gitworkshop.dev/repo/grasp/01.md" target="_blank">GRASP-01 Specification</a></li>
<li><a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank">NIP-34: Git Stuff</a></li>
</ul>
<div class="footer">
<p>Powered by <strong>ngit-grasp</strong></p>
</div>
</div>
</body>
</html>"#,
relay_name = config.relay_name,
npub = npub,
identifier = identifier,
clone_url = clone_url,
)
}
+99 -7
View File
@@ -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 `/<npub>/<identifier>.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<Request<Incoming>> 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<Request<Incoming>> 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())
}
})
}
}
+2
View File
@@ -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);