feat: implement a basic backend (#1)
Reviewed-on: https://git.reya.su/reya/signed/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "signed_core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RepoAddr {
|
||||
pub owner: PublicKey,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl RepoAddr {
|
||||
pub fn new(owner: PublicKey, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
owner,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The NIP-33 coordinate for the announcement event (`a` tag value).
|
||||
pub fn coordinate(&self) -> Coordinate {
|
||||
Coordinate::new(Kind::GitRepoAnnouncement, self.owner).identifier(self.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RepoAddr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.coordinate())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RepoAddr {
|
||||
type Err = nostr::error::Error;
|
||||
|
||||
/// Parse from `<kind>:<pubkey>:<d-tag>`, `naddr1...` bech32 or `nostr:naddr1...` URI.
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let coordinate = Coordinate::parse(s)?;
|
||||
Ok(Self {
|
||||
owner: coordinate.public_key,
|
||||
id: coordinate.identifier,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format").
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` — direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
/// `npub1...` or a NIP-05 identifier.
|
||||
user: String,
|
||||
relay_hint: Option<RelayUrl>,
|
||||
/// `d` tag identifier of the repository.
|
||||
identifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
|
||||
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
let mut parts = rest.split('/');
|
||||
|
||||
let first = parts.next()?;
|
||||
let second = parts.next()?;
|
||||
let third = parts.next();
|
||||
|
||||
if first.starts_with("naddr1") {
|
||||
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
|
||||
return Some(CloneTarget::Addr(RepoAddr::new(
|
||||
coordinate.coordinate.public_key,
|
||||
coordinate.coordinate.identifier,
|
||||
)));
|
||||
}
|
||||
|
||||
let (relay_hint, identifier) = match third {
|
||||
Some(id) => (
|
||||
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||
percent_decode(id),
|
||||
),
|
||||
None => (None, percent_decode(second)),
|
||||
};
|
||||
|
||||
Some(CloneTarget::UserRepo {
|
||||
user: first.to_owned(),
|
||||
relay_hint,
|
||||
identifier,
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = &input[i + 1..i + 3];
|
||||
if let Ok(v) = u8::from_str_radix(hex, 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_without_relay() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
|
||||
relay_hint: None,
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_with_relay_hint() {
|
||||
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_parts() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||
identifier: "my-local-only-repo".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use nostr::filter::{Alphabet, SingleLetterTag};
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Kinds that make up the activity of a repository.
|
||||
pub const ACTIVITY_KINDS: [Kind; 8] = [
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPullRequestUpdate,
|
||||
Kind::GitIssue,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Latest announcement event for a repository.
|
||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(addr.owner)
|
||||
.identifier(addr.id.clone())
|
||||
}
|
||||
|
||||
/// Latest state event (refs / HEAD) for a repository.
|
||||
pub fn state(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
.author(addr.owner)
|
||||
.identifier(addr.id.clone())
|
||||
}
|
||||
|
||||
/// All NIP-34 activity addressed to a repository (`#a` tag).
|
||||
///
|
||||
/// Note: the `a` tag on status events is optional per NIP-34, so statuses
|
||||
/// published without it won't be matched here.
|
||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(ACTIVITY_KINDS)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), addr.to_string())
|
||||
}
|
||||
|
||||
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
|
||||
pub fn statuses_for(root: EventId) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.event(root)
|
||||
}
|
||||
|
||||
/// A user's grasp list (kind `10317`).
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// All repositories announced by an author.
|
||||
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// All repository announcements (for global discovery).
|
||||
pub fn all_announcements(limit: usize) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.limit(limit)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod addr;
|
||||
pub mod clone_url;
|
||||
pub mod filters;
|
||||
pub mod model;
|
||||
pub mod status;
|
||||
|
||||
pub use addr::RepoAddr;
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use model::Announcement;
|
||||
pub use status::{RepoStatus, references_root, resolve_status};
|
||||
@@ -0,0 +1,86 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
/// Author of the announcement event.
|
||||
pub owner: PublicKey,
|
||||
/// When the announcement was published (for latest-wins resolution).
|
||||
pub created_at: Timestamp,
|
||||
/// Repository ID (`d` tag).
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Webpage URLs for browsing.
|
||||
pub web: Vec<String>,
|
||||
/// URLs for `git clone`.
|
||||
pub clone: Vec<String>,
|
||||
/// Relays the repository monitors for patches and issues.
|
||||
pub relays: Vec<String>,
|
||||
/// Earliest unique commit ID (`r` tag with `euc` marker).
|
||||
pub euc: Option<String>,
|
||||
/// Other recognized maintainers.
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
if event.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut id: Option<String> = None;
|
||||
let mut name: Option<String> = None;
|
||||
let mut description: Option<String> = None;
|
||||
let mut web: Vec<String> = Vec::new();
|
||||
let mut clone: Vec<String> = Vec::new();
|
||||
let mut relays: Vec<String> = Vec::new();
|
||||
let mut euc: Option<String> = None;
|
||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let values: &[String] = tag.as_slice();
|
||||
match tag.kind() {
|
||||
"d" => id = tag.content().map(str::to_owned),
|
||||
"name" => name = tag.content().map(str::to_owned),
|
||||
"description" => description = tag.content().map(str::to_owned),
|
||||
"web" => web.extend(values.iter().skip(1).cloned()),
|
||||
"clone" => clone.extend(values.iter().skip(1).cloned()),
|
||||
"relays" => relays.extend(values.iter().skip(1).cloned()),
|
||||
"r" => {
|
||||
if values.get(2).map(String::as_str) == Some("euc") {
|
||||
euc = tag.content().map(str::to_owned);
|
||||
}
|
||||
}
|
||||
"maintainers" => {
|
||||
maintainers.extend(
|
||||
values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|v| PublicKey::from_hex(v).ok()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
owner: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
id: id?,
|
||||
name,
|
||||
description,
|
||||
web,
|
||||
clone,
|
||||
relays,
|
||||
euc,
|
||||
maintainers,
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository address of this announcement.
|
||||
pub fn addr(&self) -> crate::RepoAddr {
|
||||
crate::RepoAddr::new(self.owner, self.id.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Status of a root patch, pull request or issue (kinds `1630..=1633`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RepoStatus {
|
||||
Open,
|
||||
Applied,
|
||||
Closed,
|
||||
Draft,
|
||||
}
|
||||
|
||||
impl RepoStatus {
|
||||
pub fn from_kind(kind: Kind) -> Option<Self> {
|
||||
match kind {
|
||||
Kind::GitStatusOpen => Some(Self::Open),
|
||||
Kind::GitStatusApplied => Some(Self::Applied),
|
||||
Kind::GitStatusClosed => Some(Self::Closed),
|
||||
Kind::GitStatusDraft => Some(Self::Draft),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(self) -> Kind {
|
||||
match self {
|
||||
Self::Open => Kind::GitStatusOpen,
|
||||
Self::Applied => Kind::GitStatusApplied,
|
||||
Self::Closed => Kind::GitStatusClosed,
|
||||
Self::Draft => Kind::GitStatusDraft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a status event references the given root event via an `e` tag.
|
||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
let root_hex: String = root.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.kind() == "e" && t.content() == Some(root_hex.as_str()))
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event per NIP-34:
|
||||
/// the most recent status event from the root author or a maintainer wins.
|
||||
/// Defaults to [`RepoStatus::Open`].
|
||||
pub fn resolve_status<'a, I>(
|
||||
status_events: I,
|
||||
root_author: &PublicKey,
|
||||
maintainers: &[PublicKey],
|
||||
) -> RepoStatus
|
||||
where
|
||||
I: IntoIterator<Item = &'a Event>,
|
||||
{
|
||||
status_events
|
||||
.into_iter()
|
||||
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||
.max_by_key(|e| e.created_at)
|
||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||
.unwrap_or(RepoStatus::Open)
|
||||
}
|
||||
Reference in New Issue
Block a user