upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/nostr/events.rs606
-rw-r--r--src/nostr/mod.rs1
-rw-r--r--src/nostr/relay.rs32
-rw-r--r--src/storage/mod.rs6
4 files changed, 644 insertions, 1 deletions
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
new file mode 100644
index 0000000..88aefed
--- /dev/null
+++ b/src/nostr/events.rs
@@ -0,0 +1,606 @@
1/// NIP-34 Git Repository Event Handling
2///
3/// This module handles Git repository announcements (kind 30617) and
4/// repository state announcements (kind 30618) according to NIP-34 and GRASP-01.
5///
6/// Reference:
7/// - NIP-34: https://nips.nostr.com/34
8/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
9
10use anyhow::{anyhow, Result};
11use nostr_sdk::{Event, Kind, TagKind, ToBech32};
12
13/// NIP-34 Repository Announcement (kind 30617)
14pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
15
16/// NIP-34 Repository State Announcement (kind 30618)
17pub const KIND_REPOSITORY_STATE: u16 = 30618;
18
19/// Repository announcement details extracted from NIP-34 event
20#[derive(Debug, Clone)]
21pub struct RepositoryAnnouncement {
22 pub event: Event,
23 pub identifier: String,
24 pub name: Option<String>,
25 pub description: Option<String>,
26 pub clone_urls: Vec<String>,
27 pub relays: Vec<String>,
28 pub web_urls: Vec<String>,
29 pub maintainers: Vec<String>,
30}
31
32impl RepositoryAnnouncement {
33 /// Parse a repository announcement from a NIP-34 kind 30617 event
34 pub fn from_event(event: Event) -> Result<Self> {
35 if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
36 return Err(anyhow!(
37 "Invalid event kind: expected {}, got {}",
38 KIND_REPOSITORY_ANNOUNCEMENT,
39 event.kind
40 ));
41 }
42
43 // Extract identifier (required)
44 let identifier = event
45 .tags
46 .iter()
47 .find(|t| t.kind() == TagKind::d())
48 .and_then(|t| t.content())
49 .ok_or_else(|| anyhow!("Repository announcement missing 'd' tag (identifier)"))?
50 .to_string();
51
52 // Extract optional name
53 let name = event
54 .tags
55 .iter()
56 .find(|t| matches!(t.kind(), TagKind::Name))
57 .and_then(|t| t.content())
58 .map(|s| s.to_string());
59
60 // Extract description from content
61 let description = if event.content.is_empty() {
62 None
63 } else {
64 Some(event.content.clone())
65 };
66
67 // Extract clone URLs
68 let clone_urls = event
69 .tags
70 .iter()
71 .filter(|t| matches!(t.kind(), TagKind::Clone))
72 .flat_map(|t| {
73 let vec = t.clone().to_vec();
74 // Skip first element (tag name), rest are values
75 vec.into_iter().skip(1)
76 })
77 .collect();
78
79 // Extract relays
80 let relays = event
81 .tags
82 .iter()
83 .filter(|t| matches!(t.kind(), TagKind::Relays))
84 .flat_map(|t| {
85 let vec = t.clone().to_vec();
86 // Skip first element (tag name), rest are values
87 vec.into_iter().skip(1)
88 })
89 .collect();
90
91 // Extract web URLs
92 let web_urls = event
93 .tags
94 .iter()
95 .filter(|t| {
96 if let TagKind::Custom(s) = t.kind() {
97 s.as_ref() == "web"
98 } else {
99 false
100 }
101 })
102 .flat_map(|t| {
103 let vec = t.clone().to_vec();
104 // Skip first element (tag name), rest are values
105 vec.into_iter().skip(1)
106 })
107 .collect();
108
109 // Extract maintainers (other-user tags)
110 let maintainers = event
111 .tags
112 .iter()
113 .filter(|t| t.kind() == TagKind::p())
114 .filter_map(|t| t.content())
115 .map(|s| s.to_string())
116 .collect();
117
118 Ok(RepositoryAnnouncement {
119 event,
120 identifier,
121 name,
122 description,
123 clone_urls,
124 relays,
125 web_urls,
126 maintainers,
127 })
128 }
129
130 /// Check if this announcement lists the given domain in clone URLs
131 pub fn has_clone_url(&self, domain: &str) -> bool {
132 self.clone_urls.iter().any(|url| url.contains(domain))
133 }
134
135 /// Check if this announcement lists the given relay
136 pub fn has_relay(&self, relay: &str) -> bool {
137 self.relays.iter().any(|r| r.contains(relay))
138 }
139
140 /// Check if this announcement lists the service (both clone and relay)
141 ///
142 /// GRASP-01 requirement: MUST reject announcements that do not list
143 /// the service in both `clone` and `relays` tags unless implementing GRASP-05.
144 pub fn lists_service(&self, domain: &str) -> bool {
145 self.has_clone_url(domain) && self.has_relay(domain)
146 }
147
148 /// Get the npub of the repository owner
149 pub fn owner_npub(&self) -> String {
150 self.event.pubkey.to_bech32().unwrap_or_default()
151 }
152
153 /// Get the repository path: <npub>/<identifier>.git
154 pub fn repo_path(&self) -> String {
155 format!("{}/{}.git", self.owner_npub(), self.identifier)
156 }
157}
158
159/// Repository state details extracted from NIP-34 event
160#[derive(Debug, Clone)]
161pub struct RepositoryState {
162 pub event: Event,
163 pub identifier: String,
164 pub branches: Vec<BranchState>,
165 pub tags: Vec<TagState>,
166}
167
168/// Branch state (ref with commit hash)
169#[derive(Debug, Clone)]
170pub struct BranchState {
171 pub name: String,
172 pub commit: String,
173}
174
175/// Tag state (ref with commit hash)
176#[derive(Debug, Clone)]
177pub struct TagState {
178 pub name: String,
179 pub commit: String,
180}
181
182impl RepositoryState {
183 /// Parse a repository state from a NIP-34 kind 30618 event
184 pub fn from_event(event: Event) -> Result<Self> {
185 if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
186 return Err(anyhow!(
187 "Invalid event kind: expected {}, got {}",
188 KIND_REPOSITORY_STATE,
189 event.kind
190 ));
191 }
192
193 // Extract identifier (required)
194 let identifier = event
195 .tags
196 .iter()
197 .find(|t| t.kind() == TagKind::d())
198 .and_then(|t| t.content())
199 .ok_or_else(|| anyhow!("Repository state missing 'd' tag (identifier)"))?
200 .to_string();
201
202 // Extract branches (refs/heads/*)
203 let branches = event
204 .tags
205 .iter()
206 .filter(|t| {
207 if let TagKind::Custom(s) = t.kind() {
208 s.as_ref() == "ref"
209 } else {
210 false
211 }
212 })
213 .filter_map(|t| {
214 let parts = t.clone().to_vec();
215 if parts.len() >= 3 && parts[1].starts_with("refs/heads/") {
216 Some(BranchState {
217 name: parts[1].strip_prefix("refs/heads/").unwrap().to_string(),
218 commit: parts[2].clone(),
219 })
220 } else {
221 None
222 }
223 })
224 .collect();
225
226 // Extract tags (refs/tags/*)
227 let tags = event
228 .tags
229 .iter()
230 .filter(|t| {
231 if let TagKind::Custom(s) = t.kind() {
232 s.as_ref() == "ref"
233 } else {
234 false
235 }
236 })
237 .filter_map(|t| {
238 let parts = t.clone().to_vec();
239 if parts.len() >= 3 && parts[1].starts_with("refs/tags/") {
240 Some(TagState {
241 name: parts[1].strip_prefix("refs/tags/").unwrap().to_string(),
242 commit: parts[2].clone(),
243 })
244 } else {
245 None
246 }
247 })
248 .collect();
249
250 Ok(RepositoryState {
251 event,
252 identifier,
253 branches,
254 tags,
255 })
256 }
257
258 /// Get the commit hash for a branch
259 pub fn get_branch_commit(&self, branch: &str) -> Option<&str> {
260 self.branches
261 .iter()
262 .find(|b| b.name == branch)
263 .map(|b| b.commit.as_str())
264 }
265
266 /// Get the commit hash for a tag
267 pub fn get_tag_commit(&self, tag: &str) -> Option<&str> {
268 self.tags
269 .iter()
270 .find(|t| t.name == tag)
271 .map(|t| t.commit.as_str())
272 }
273
274 /// Get the owner npub
275 pub fn owner_npub(&self) -> String {
276 self.event.pubkey.to_bech32().unwrap_or_default()
277 }
278}
279
280/// Validate a repository announcement according to GRASP-01
281///
282/// Returns Ok(()) if valid, Err with reason if invalid.
283pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
284 // Must be kind 30617
285 if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
286 return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_ANNOUNCEMENT));
287 }
288
289 // Must have identifier
290 let has_identifier = event
291 .tags
292 .iter()
293 .any(|t| t.kind() == TagKind::d());
294 if !has_identifier {
295 return Err(anyhow!("Missing required 'd' tag (identifier)"));
296 }
297
298 // Parse full announcement to validate structure
299 let announcement = RepositoryAnnouncement::from_event(event.clone())?;
300
301 // GRASP-01: MUST reject announcements that do not list the service
302 // in both `clone` and `relays` tags unless implementing GRASP-05
303 if !announcement.lists_service(domain) {
304 return Err(anyhow!(
305 "Announcement must list service in both 'clone' and 'relays' tags. \
306 Found clone URLs: {:?}, relays: {:?}",
307 announcement.clone_urls,
308 announcement.relays
309 ));
310 }
311
312 Ok(())
313}
314
315/// Validate a repository state announcement according to GRASP-01
316///
317/// Returns Ok(()) if valid, Err with reason if invalid.
318pub fn validate_state(event: &Event) -> Result<()> {
319 // Must be kind 30618
320 if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
321 return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_STATE));
322 }
323
324 // Must have identifier
325 let has_identifier = event
326 .tags
327 .iter()
328 .any(|t| t.kind() == TagKind::d());
329 if !has_identifier {
330 return Err(anyhow!("Missing required 'd' tag (identifier)"));
331 }
332
333 // Parse full state to validate structure
334 let _state = RepositoryState::from_event(event.clone())?;
335
336 Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use nostr_sdk::{EventBuilder, Keys, Tag};
343
344 fn create_test_keys() -> Keys {
345 Keys::generate()
346 }
347
348 fn create_announcement_event(
349 keys: &Keys,
350 identifier: &str,
351 clone_urls: Vec<&str>,
352 relays: Vec<&str>,
353 ) -> Event {
354 use nostr_sdk::Tag;
355
356 let mut tags = vec![Tag::custom(
357 nostr_sdk::TagKind::d(),
358 vec![identifier.to_string()],
359 )];
360
361 for url in clone_urls {
362 tags.push(Tag::custom(
363 nostr_sdk::TagKind::Clone,
364 vec![url.to_string()],
365 ));
366 }
367
368 for relay in relays {
369 tags.push(Tag::custom(
370 nostr_sdk::TagKind::Relays,
371 vec![relay.to_string()],
372 ));
373 }
374
375 EventBuilder::new(
376 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
377 "Test repository",
378 )
379 .tags(tags)
380 .sign_with_keys(keys)
381 .unwrap()
382 }
383
384 fn create_state_event(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> Event {
385 use nostr_sdk::Tag;
386
387 let mut tags = vec![Tag::custom(
388 nostr_sdk::TagKind::d(),
389 vec![identifier.to_string()],
390 )];
391
392 for (branch, commit) in branches {
393 tags.push(Tag::custom(
394 nostr_sdk::TagKind::Custom("ref".into()),
395 vec![
396 format!("refs/heads/{}", branch),
397 commit.to_string(),
398 ],
399 ));
400 }
401
402 EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
403 .tags(tags)
404 .sign_with_keys(keys)
405 .unwrap()
406 }
407
408 #[test]
409 fn test_parse_announcement() {
410 let keys = create_test_keys();
411 let event = create_announcement_event(
412 &keys,
413 "test-repo",
414 vec!["https://gitnostr.com/alice/test-repo.git"],
415 vec!["wss://gitnostr.com"],
416 );
417
418 let announcement = RepositoryAnnouncement::from_event(event).unwrap();
419
420 assert_eq!(announcement.identifier, "test-repo");
421 assert_eq!(announcement.clone_urls.len(), 1);
422 assert_eq!(announcement.relays.len(), 1);
423 assert!(announcement.has_clone_url("gitnostr.com"));
424 assert!(announcement.has_relay("gitnostr.com"));
425 assert!(announcement.lists_service("gitnostr.com"));
426 }
427
428 #[test]
429 fn test_parse_announcement_missing_identifier() {
430 let keys = create_test_keys();
431 let event = EventBuilder::new(
432 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
433 "Test repository",
434 )
435 .sign_with_keys(&keys)
436 .unwrap();
437
438 let result = RepositoryAnnouncement::from_event(event);
439 assert!(result.is_err());
440 assert!(result.unwrap_err().to_string().contains("identifier"));
441 }
442
443 #[test]
444 fn test_parse_state() {
445 let keys = create_test_keys();
446 let event = create_state_event(
447 &keys,
448 "test-repo",
449 vec![("main", "a1b2c3d4"), ("develop", "e5f6g7h8")],
450 );
451
452 let state = RepositoryState::from_event(event).unwrap();
453
454 assert_eq!(state.identifier, "test-repo");
455 assert_eq!(state.branches.len(), 2);
456 assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
457 assert_eq!(state.get_branch_commit("develop"), Some("e5f6g7h8"));
458 }
459
460 #[test]
461 fn test_validate_announcement_success() {
462 let keys = create_test_keys();
463 let event = create_announcement_event(
464 &keys,
465 "test-repo",
466 vec!["https://gitnostr.com/alice/test-repo.git"],
467 vec!["wss://gitnostr.com"],
468 );
469
470 let result = validate_announcement(&event, "gitnostr.com");
471 assert!(result.is_ok());
472 }
473
474 #[test]
475 fn test_validate_announcement_missing_clone() {
476 let keys = create_test_keys();
477 let event = create_announcement_event(
478 &keys,
479 "test-repo",
480 vec![], // No clone URLs
481 vec!["wss://gitnostr.com"],
482 );
483
484 let result = validate_announcement(&event, "gitnostr.com");
485 assert!(result.is_err());
486 assert!(result.unwrap_err().to_string().contains("clone"));
487 }
488
489 #[test]
490 fn test_validate_announcement_missing_relay() {
491 let keys = create_test_keys();
492 let event = create_announcement_event(
493 &keys,
494 "test-repo",
495 vec!["https://gitnostr.com/alice/test-repo.git"],
496 vec![], // No relays
497 );
498
499 let result = validate_announcement(&event, "gitnostr.com");
500 assert!(result.is_err());
501 assert!(result.unwrap_err().to_string().contains("relays"));
502 }
503
504 #[test]
505 fn test_validate_announcement_wrong_domain() {
506 let keys = create_test_keys();
507 let event = create_announcement_event(
508 &keys,
509 "test-repo",
510 vec!["https://other-service.com/alice/test-repo.git"],
511 vec!["wss://other-service.com"],
512 );
513
514 let result = validate_announcement(&event, "gitnostr.com");
515 assert!(result.is_err());
516 }
517
518 #[test]
519 fn test_validate_state_success() {
520 let keys = create_test_keys();
521 let event = create_state_event(&keys, "test-repo", vec![("main", "a1b2c3d4")]);
522
523 let result = validate_state(&event);
524 assert!(result.is_ok());
525 }
526
527 #[test]
528 fn test_validate_state_missing_identifier() {
529 let keys = create_test_keys();
530 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
531 .sign_with_keys(&keys)
532 .unwrap();
533
534 let result = validate_state(&event);
535 assert!(result.is_err());
536 assert!(result.unwrap_err().to_string().contains("identifier"));
537 }
538
539 #[test]
540 fn test_announcement_maintainers() {
541 use nostr_sdk::Tag;
542
543 let keys = create_test_keys();
544 let maintainer_keys = create_test_keys();
545
546 let mut tags = vec![
547 Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]),
548 Tag::custom(
549 nostr_sdk::TagKind::Clone,
550 vec!["https://gitnostr.com/alice/test-repo.git".to_string()],
551 ),
552 Tag::custom(
553 nostr_sdk::TagKind::Relays,
554 vec!["wss://gitnostr.com".to_string()],
555 ),
556 ];
557
558 // Add maintainer
559 tags.push(Tag::public_key(maintainer_keys.public_key()));
560
561 let event = EventBuilder::new(
562 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
563 "Test repository",
564 )
565 .tags(tags)
566 .sign_with_keys(&keys)
567 .unwrap();
568
569 let announcement = RepositoryAnnouncement::from_event(event).unwrap();
570 assert_eq!(announcement.maintainers.len(), 1);
571 }
572
573 #[test]
574 fn test_state_with_tags() {
575 use nostr_sdk::Tag;
576
577 let keys = create_test_keys();
578 let mut tags = vec![Tag::custom(
579 nostr_sdk::TagKind::d(),
580 vec!["test-repo".to_string()],
581 )];
582
583 // Add branch
584 tags.push(Tag::custom(
585 nostr_sdk::TagKind::Custom("ref".into()),
586 vec!["refs/heads/main".to_string(), "a1b2c3d4".to_string()],
587 ));
588
589 // Add tag
590 tags.push(Tag::custom(
591 nostr_sdk::TagKind::Custom("ref".into()),
592 vec!["refs/tags/v1.0.0".to_string(), "e5f6g7h8".to_string()],
593 ));
594
595 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
596 .tags(tags)
597 .sign_with_keys(&keys)
598 .unwrap();
599
600 let state = RepositoryState::from_event(event).unwrap();
601 assert_eq!(state.branches.len(), 1);
602 assert_eq!(state.tags.len(), 1);
603 assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
604 assert_eq!(state.get_tag_commit("v1.0.0"), Some("e5f6g7h8"));
605 }
606}
diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs
index 6193dd9..b485b91 100644
--- a/src/nostr/mod.rs
+++ b/src/nostr/mod.rs
@@ -1 +1,2 @@
1pub mod events;
1pub mod relay; 2pub mod relay;
diff --git a/src/nostr/relay.rs b/src/nostr/relay.rs
index 5af9b04..1033b5b 100644
--- a/src/nostr/relay.rs
+++ b/src/nostr/relay.rs
@@ -1,6 +1,6 @@
1use anyhow::Result; 1use anyhow::Result;
2use futures_util::{SinkExt, StreamExt}; 2use futures_util::{SinkExt, StreamExt};
3use nostr_sdk::{Event, EventId, Filter}; 3use nostr_sdk::{Event, EventId, Filter, Kind};
4use serde_json::{json, Value}; 4use serde_json::{json, Value};
5use std::collections::HashMap; 5use std::collections::HashMap;
6use std::net::SocketAddr; 6use std::net::SocketAddr;
@@ -11,6 +11,7 @@ use tokio_tungstenite::{accept_async, tungstenite::Message};
11use tracing::{debug, error, info, warn}; 11use tracing::{debug, error, info, warn};
12 12
13use crate::config::Config; 13use crate::config::Config;
14use crate::nostr::events::{validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE};
14use crate::storage::Storage; 15use crate::storage::Storage;
15 16
16type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>; 17type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>;
@@ -140,6 +141,35 @@ async fn handle_event(arr: &[Value], storage: &Storage) -> Result<Vec<Value>> {
140 return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]); 141 return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]);
141 } 142 }
142 143
144 // Validate repository announcements (kind 30617)
145 if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
146 // Get domain from storage config
147 let domain = storage.get_domain();
148
149 match validate_announcement(&event, &domain) {
150 Ok(()) => {
151 info!("✅ Valid repository announcement: {} ({})", event_id, event.kind);
152 }
153 Err(e) => {
154 warn!("❌ Invalid repository announcement: {}", e);
155 return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
156 }
157 }
158 }
159
160 // Validate repository state announcements (kind 30618)
161 if event.kind == Kind::from(KIND_REPOSITORY_STATE) {
162 match validate_state(&event) {
163 Ok(()) => {
164 info!("✅ Valid repository state: {} ({})", event_id, event.kind);
165 }
166 Err(e) => {
167 warn!("❌ Invalid repository state: {}", e);
168 return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
169 }
170 }
171 }
172
143 // Store the event 173 // Store the event
144 storage.store_event(event.clone()).await?; 174 storage.store_event(event.clone()).await?;
145 175
diff --git a/src/storage/mod.rs b/src/storage/mod.rs
index 2ec6d4e..eab8211 100644
--- a/src/storage/mod.rs
+++ b/src/storage/mod.rs
@@ -12,6 +12,7 @@ use crate::config::Config;
12pub struct Storage { 12pub struct Storage {
13 events: Arc<RwLock<HashMap<String, Event>>>, 13 events: Arc<RwLock<HashMap<String, Event>>>,
14 data_path: String, 14 data_path: String,
15 domain: String,
15} 16}
16 17
17impl Storage { 18impl Storage {
@@ -22,9 +23,14 @@ impl Storage {
22 Ok(Storage { 23 Ok(Storage {
23 events: Arc::new(RwLock::new(HashMap::new())), 24 events: Arc::new(RwLock::new(HashMap::new())),
24 data_path: config.relay_data_path.clone(), 25 data_path: config.relay_data_path.clone(),
26 domain: config.domain.clone(),
25 }) 27 })
26 } 28 }
27 29
30 pub fn get_domain(&self) -> String {
31 self.domain.clone()
32 }
33
28 pub async fn store_event(&self, event: Event) -> Result<()> { 34 pub async fn store_event(&self, event: Event) -> Result<()> {
29 let mut events = self.events.write().await; 35 let mut events = self.events.write().await;
30 events.insert(event.id.to_hex(), event); 36 events.insert(event.id.to_hex(), event);