upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/http
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-01 23:47:27 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-01 23:47:27 +0000
commit59dbbf0f2986e8d969cc30b57d70f76984a272e3 (patch)
tree303601548a7444dbfbe797684a8cd3371005b0ae /src/http
parente6c056023bac4a83930b9c40f4a9513c3680cb67 (diff)
add repo land page and 404 page per GRASP-01
Diffstat (limited to 'src/http')
-rw-r--r--src/http/landing.rs407
-rw-r--r--src/http/mod.rs106
2 files changed, 506 insertions, 7 deletions
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 {
13 bind_address = config.bind_address, 13 bind_address = config.bind_address,
14 ) 14 )
15} 15}
16
17/// Generate a generic 404 page for unknown paths
18///
19/// Used for any path that doesn't match a known route
20pub fn get_generic_404_html(config: &Config, path: &str) -> String {
21 format!(
22 r#"<!DOCTYPE html>
23<html lang="en">
24<head>
25 <meta charset="UTF-8">
26 <meta name="viewport" content="width=device-width, initial-scale=1.0">
27 <title>Not Found - {relay_name}</title>
28 <style>
29 * {{
30 margin: 0;
31 padding: 0;
32 box-sizing: border-box;
33 }}
34 body {{
35 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
36 line-height: 1.6;
37 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
38 color: #333;
39 min-height: 100vh;
40 display: flex;
41 align-items: center;
42 justify-content: center;
43 padding: 20px;
44 }}
45 .container {{
46 max-width: 600px;
47 background: white;
48 padding: 40px;
49 border-radius: 12px;
50 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
51 text-align: center;
52 }}
53 h1 {{
54 color: #e74c3c;
55 margin-bottom: 10px;
56 font-size: 4em;
57 }}
58 h2 {{
59 color: #333;
60 margin-bottom: 20px;
61 font-size: 1.5em;
62 }}
63 .path-info {{
64 background: #f9f9f9;
65 padding: 15px;
66 border-radius: 8px;
67 margin: 20px 0;
68 border-left: 4px solid #e74c3c;
69 }}
70 code {{
71 background: #f4f4f4;
72 padding: 3px 8px;
73 border-radius: 4px;
74 font-family: 'Courier New', monospace;
75 color: #667eea;
76 font-size: 0.85em;
77 word-break: break-all;
78 }}
79 .back-link {{
80 margin-top: 20px;
81 }}
82 a {{
83 color: #667eea;
84 text-decoration: none;
85 }}
86 a:hover {{
87 text-decoration: underline;
88 }}
89 .footer {{
90 margin-top: 30px;
91 padding-top: 20px;
92 border-top: 1px solid #eee;
93 color: #999;
94 font-size: 0.9em;
95 }}
96 </style>
97</head>
98<body>
99 <div class="container">
100 <h1>404</h1>
101 <h2>Not Found</h2>
102 <p>The page you're looking for doesn't exist.</p>
103
104 <div class="path-info">
105 <p><strong>Requested path:</strong> <code>{path}</code></p>
106 </div>
107
108 <div class="back-link">
109 <a href="/">← Back to {relay_name}</a>
110 </div>
111
112 <div class="footer">
113 <p>Powered by <strong>ngit-grasp</strong></p>
114 </div>
115 </div>
116</body>
117</html>"#,
118 relay_name = config.relay_name,
119 path = path,
120 )
121}
122
123/// Generate a 404 page for a non-existent repository
124///
125/// GRASP-01: "...and a 404 page for repositories it doesn't host"
126pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String {
127 format!(
128 r#"<!DOCTYPE html>
129<html lang="en">
130<head>
131 <meta charset="UTF-8">
132 <meta name="viewport" content="width=device-width, initial-scale=1.0">
133 <title>Repository Not Found - {relay_name}</title>
134 <style>
135 * {{
136 margin: 0;
137 padding: 0;
138 box-sizing: border-box;
139 }}
140 body {{
141 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
142 line-height: 1.6;
143 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
144 color: #333;
145 min-height: 100vh;
146 display: flex;
147 align-items: center;
148 justify-content: center;
149 padding: 20px;
150 }}
151 .container {{
152 max-width: 600px;
153 background: white;
154 padding: 40px;
155 border-radius: 12px;
156 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
157 text-align: center;
158 }}
159 h1 {{
160 color: #e74c3c;
161 margin-bottom: 10px;
162 font-size: 4em;
163 }}
164 h2 {{
165 color: #333;
166 margin-bottom: 20px;
167 font-size: 1.5em;
168 }}
169 .repo-info {{
170 background: #f9f9f9;
171 padding: 15px;
172 border-radius: 8px;
173 margin: 20px 0;
174 border-left: 4px solid #e74c3c;
175 }}
176 code {{
177 background: #f4f4f4;
178 padding: 3px 8px;
179 border-radius: 4px;
180 font-family: 'Courier New', monospace;
181 color: #667eea;
182 font-size: 0.85em;
183 word-break: break-all;
184 }}
185 .back-link {{
186 margin-top: 20px;
187 }}
188 a {{
189 color: #667eea;
190 text-decoration: none;
191 }}
192 a:hover {{
193 text-decoration: underline;
194 }}
195 .footer {{
196 margin-top: 30px;
197 padding-top: 20px;
198 border-top: 1px solid #eee;
199 color: #999;
200 font-size: 0.9em;
201 }}
202 </style>
203</head>
204<body>
205 <div class="container">
206 <h1>404</h1>
207 <h2>Repository Not Found</h2>
208 <p>The repository you're looking for doesn't exist on this GRASP server.</p>
209
210 <div class="repo-info">
211 <p><strong>Owner:</strong> <code>{npub}</code></p>
212 <p><strong>Repository:</strong> <code>{identifier}</code></p>
213 </div>
214
215 <p>This repository may not have been announced to this server, or the URL may be incorrect.</p>
216
217 <div class="back-link">
218 <a href="/">← Back to {relay_name}</a>
219 </div>
220
221 <div class="footer">
222 <p>Powered by <strong>ngit-grasp</strong></p>
223 </div>
224 </div>
225</body>
226</html>"#,
227 relay_name = config.relay_name,
228 npub = npub,
229 identifier = identifier,
230 )
231}
232
233/// Generate a webpage for an existing repository
234///
235/// GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s)
236/// to browse the repository"
237pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String {
238 let clone_url = format!(
239 "http://{}/{}/{}.git",
240 config.domain, npub, identifier
241 );
242
243 format!(
244 r#"<!DOCTYPE html>
245<html lang="en">
246<head>
247 <meta charset="UTF-8">
248 <meta name="viewport" content="width=device-width, initial-scale=1.0">
249 <title>{identifier} - {relay_name}</title>
250 <style>
251 * {{
252 margin: 0;
253 padding: 0;
254 box-sizing: border-box;
255 }}
256 body {{
257 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
258 line-height: 1.6;
259 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
260 color: #333;
261 min-height: 100vh;
262 display: flex;
263 align-items: center;
264 justify-content: center;
265 padding: 20px;
266 }}
267 .container {{
268 max-width: 800px;
269 background: white;
270 padding: 40px;
271 border-radius: 12px;
272 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
273 }}
274 h1 {{
275 color: #667eea;
276 margin-bottom: 10px;
277 font-size: 2em;
278 }}
279 h2 {{
280 color: #764ba2;
281 margin-top: 25px;
282 margin-bottom: 15px;
283 font-size: 1.3em;
284 border-bottom: 2px solid #667eea;
285 padding-bottom: 8px;
286 }}
287 .subtitle {{
288 color: #666;
289 margin-bottom: 25px;
290 font-size: 1em;
291 }}
292 .repo-info {{
293 background: #f9f9f9;
294 padding: 15px;
295 border-radius: 8px;
296 margin: 15px 0;
297 border-left: 4px solid #667eea;
298 }}
299 code {{
300 background: #f4f4f4;
301 padding: 3px 8px;
302 border-radius: 4px;
303 font-family: 'Courier New', monospace;
304 color: #667eea;
305 font-size: 0.85em;
306 word-break: break-all;
307 }}
308 .clone-box {{
309 background: #2d3748;
310 color: #e2e8f0;
311 padding: 15px;
312 border-radius: 8px;
313 margin: 15px 0;
314 font-family: 'Courier New', monospace;
315 font-size: 0.9em;
316 overflow-x: auto;
317 }}
318 .clone-box code {{
319 background: transparent;
320 color: #68d391;
321 padding: 0;
322 }}
323 ul {{
324 margin: 15px 0;
325 padding-left: 25px;
326 }}
327 li {{
328 margin: 10px 0;
329 }}
330 a {{
331 color: #667eea;
332 text-decoration: none;
333 }}
334 a:hover {{
335 text-decoration: underline;
336 }}
337 .client-list {{
338 display: grid;
339 gap: 10px;
340 margin: 15px 0;
341 }}
342 .client-item {{
343 background: #f9f9f9;
344 padding: 12px 15px;
345 border-radius: 8px;
346 display: flex;
347 justify-content: space-between;
348 align-items: center;
349 }}
350 .badge {{
351 display: inline-block;
352 background: #667eea;
353 color: white;
354 padding: 4px 10px;
355 border-radius: 12px;
356 font-size: 0.8em;
357 }}
358 .footer {{
359 margin-top: 30px;
360 padding-top: 20px;
361 border-top: 1px solid #eee;
362 text-align: center;
363 color: #999;
364 font-size: 0.9em;
365 }}
366 .back-link {{
367 margin-bottom: 20px;
368 }}
369 </style>
370</head>
371<body>
372 <div class="container">
373 <div class="back-link">
374 <a href="/">← Back to {relay_name}</a>
375 </div>
376
377 <h1>📦 {identifier}</h1>
378 <p class="subtitle">Git repository hosted on {relay_name}</p>
379
380 <h2>📋 Repository Information</h2>
381 <div class="repo-info">
382 <p><strong>Owner:</strong> <code>{npub}</code></p>
383 <p><strong>Repository:</strong> <code>{identifier}</code></p>
384 </div>
385
386 <h2>🔗 Clone this Repository</h2>
387 <div class="clone-box">
388 git clone <code>{clone_url}</code>
389 </div>
390
391 <h2>🌐 Browse with Git Nostr Clients</h2>
392 <p>You can browse this repository using these Git Nostr clients:</p>
393 <div class="client-list">
394 <div class="client-item">
395 <span><strong>gitworkshop.dev</strong> - Web-based repository browser</span>
396 <a href="https://gitworkshop.dev" target="_blank">Visit →</a>
397 </div>
398 <div class="client-item">
399 <span><strong>ngit</strong> - Command-line Git + Nostr tool</span>
400 <a href="https://github.com/DanConwayDev/ngit-cli" target="_blank">GitHub →</a>
401 </div>
402 </div>
403
404 <h2>📚 About GRASP</h2>
405 <p>This repository is hosted using the <strong>GRASP</strong> (Git Relays Authorized via Signed-Nostr Proofs) protocol.</p>
406 <ul>
407 <li><a href="https://gitworkshop.dev/repo/grasp/01.md" target="_blank">GRASP-01 Specification</a></li>
408 <li><a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank">NIP-34: Git Stuff</a></li>
409 </ul>
410
411 <div class="footer">
412 <p>Powered by <strong>ngit-grasp</strong></p>
413 </div>
414 </div>
415</body>
416</html>"#,
417 relay_name = config.relay_name,
418 npub = npub,
419 identifier = identifier,
420 clone_url = clone_url,
421 )
422}
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 = "*";
32const CORS_ALLOW_METHODS: &str = "GET, POST"; 32const CORS_ALLOW_METHODS: &str = "GET, POST";
33const CORS_ALLOW_HEADERS: &str = "Content-Type"; 33const CORS_ALLOW_HEADERS: &str = "Content-Type";
34 34
35/// Extract npub and identifier from a repository URL path (no git subpath required)
36///
37/// Parses paths like `/<npub>/<identifier>.git` (for repository webpage/404)
38///
39/// Returns (npub, identifier) if the path matches a repository URL pattern
40fn parse_repo_url(path: &str) -> Option<(&str, &str)> {
41 // Remove leading slash
42 let path = path.strip_prefix('/').unwrap_or(path);
43
44 // Split into components
45 let parts: Vec<&str> = path.split('/').collect();
46
47 // Must be exactly 2 parts: npub and repo.git (no subpath)
48 if parts.len() != 2 {
49 return None;
50 }
51
52 let npub = parts[0];
53 let repo_part = parts[1];
54
55 // The repo part must end with .git
56 if !repo_part.ends_with(".git") {
57 return None;
58 }
59
60 // Must have an npub that looks valid (starts with npub1)
61 if !npub.starts_with("npub1") {
62 return None;
63 }
64
65 // Extract identifier (remove .git suffix)
66 let identifier = repo_part.strip_suffix(".git").unwrap_or(repo_part);
67
68 // Identifier must not be empty
69 if identifier.is_empty() {
70 return None;
71 }
72
73 Some((npub, identifier))
74}
75
35/// Add CORS headers to a response builder 76/// Add CORS headers to a response builder
36fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::response::Builder { 77fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::response::Builder {
37 builder 78 builder
@@ -230,6 +271,45 @@ impl Service<Request<Incoming>> for HttpService {
230 } 271 }
231 } 272 }
232 273
274 // Check for repository URL pattern (e.g., /npub/repo.git without subpath)
275 // GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s)
276 // to browse the repository and a 404 page for repositories it doesn't host"
277 if let Some((npub, identifier)) = parse_repo_url(&path) {
278 let npub = npub.to_string();
279 let identifier = identifier.to_string();
280 let config = self.config.clone();
281 let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier);
282
283 tracing::debug!(
284 "Repository URL request: {} (npub={}, id={}, path={:?})",
285 path,
286 npub,
287 identifier,
288 repo_path
289 );
290
291 return Box::pin(async move {
292 // Check if repository exists
293 if repo_path.exists() {
294 // Serve repository webpage
295 let html = landing::get_repo_html(&config, &npub, &identifier);
296 Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp"))
297 .status(200)
298 .header("content-type", "text/html; charset=utf-8")
299 .body(Full::new(Bytes::from(html)))
300 .unwrap())
301 } else {
302 // Serve 404 page for non-existent repository
303 let html = landing::get_404_html(&config, &npub, &identifier);
304 Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp"))
305 .status(404)
306 .header("content-type", "text/html; charset=utf-8")
307 .body(Full::new(Bytes::from(html)))
308 .unwrap())
309 }
310 });
311 }
312
233 // Check if this is a WebSocket upgrade request 313 // Check if this is a WebSocket upgrade request
234 if let (Some(c), Some(w)) = ( 314 if let (Some(c), Some(w)) = (
235 req.headers().get("connection"), 315 req.headers().get("connection"),
@@ -275,14 +355,26 @@ impl Service<Request<Incoming>> for HttpService {
275 } 355 }
276 } 356 }
277 357
278 // Serve landing page for HTTP requests 358 // Only serve landing page for root path "/", 404 for everything else
279 let html = landing::get_html(&self.config); 359 let config = self.config.clone();
280 Box::pin(async move { 360 Box::pin(async move {
281 Ok(base 361 if path == "/" {
282 .status(200) 362 // Serve landing page for root
283 .header("content-type", "text/html; charset=utf-8") 363 let html = landing::get_html(&config);
284 .body(Full::new(Bytes::from(html))) 364 Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp"))
285 .unwrap()) 365 .status(200)
366 .header("content-type", "text/html; charset=utf-8")
367 .body(Full::new(Bytes::from(html)))
368 .unwrap())
369 } else {
370 // Serve generic 404 for unknown paths
371 let html = landing::get_generic_404_html(&config, &path);
372 Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp"))
373 .status(404)
374 .header("content-type", "text/html; charset=utf-8")
375 .body(Full::new(Bytes::from(html)))
376 .unwrap())
377 }
286 }) 378 })
287 } 379 }
288} 380}