diff --git a/kinds.go b/kinds.go index 39ab03b..1248b0f 100644 --- a/kinds.go +++ b/kinds.go @@ -274,6 +274,10 @@ func (kind Kind) Name() string { return "VideoViewEvent" case KindCommunityDefinition: return "CommunityDefinition" + case KindNsiteRoot: + return "NsiteRoot" + case KindNsiteNamed: + return "NsiteNamed" } return "unknown" } @@ -360,6 +364,7 @@ const ( KindGoodWikiAuthorList Kind = 10101 KindGoodWikiRelayList Kind = 10102 KindNWCWalletInfo Kind = 13194 + KindNsiteRoot Kind = 15128 KindLightningPubRPC Kind = 21000 KindClientAuthentication Kind = 22242 KindNWCWalletRequest Kind = 23194 @@ -394,6 +399,7 @@ const ( KindDraftClassifiedListing Kind = 30403 KindRepositoryAnnouncement Kind = 30617 KindRepositoryState Kind = 30618 + KindNsiteNamed Kind = 35128 KindSimpleGroupMetadata Kind = 39000 KindSimpleGroupAdmins Kind = 39001 KindSimpleGroupMembers Kind = 39002 diff --git a/nip5a/helpers.go b/nip5a/helpers.go new file mode 100644 index 0000000..3406fac --- /dev/null +++ b/nip5a/helpers.go @@ -0,0 +1,38 @@ +package nip5a + +import ( + "fmt" + "math/big" + "strings" + + "fiatjaf.com/nostr" +) + +func NormalizePath(p string) string { + if !strings.HasSuffix(p, ".html") && !strings.HasSuffix(p, "/") { + return p + } + if strings.HasSuffix(p, "/") { + return p + "index.html" + } + return p +} + +func PubKeyFromBase36(value string) (nostr.PubKey, error) { + bi, ok := new(big.Int).SetString(value, 36) + if !ok { + return nostr.ZeroPK, fmt.Errorf("invalid base36 pubkey") + } + buf := bi.Bytes() + if len(buf) > 32 { + return nostr.ZeroPK, fmt.Errorf("base36 pubkey too long") + } + var pk nostr.PubKey + copy(pk[32-len(buf):], buf) + return pk, nil +} + +func PubKeyToBase36(pubkey nostr.PubKey) string { + value := new(big.Int).SetBytes(pubkey[:]).Text(36) + return strings.Repeat("0", 50-len(value)) + value +} diff --git a/nip5a/nip5a.go b/nip5a/nip5a.go new file mode 100644 index 0000000..d503e16 --- /dev/null +++ b/nip5a/nip5a.go @@ -0,0 +1,145 @@ +package nip5a + +import ( + "encoding/hex" + "fmt" + "regexp" + "strings" + "unsafe" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" +) + +type SiteManifest struct { + Event *nostr.Event + Pubkey nostr.PubKey + Root bool + Identifier string + Paths map[string][32]byte + Servers []string + Title string + Description string + Source string +} + +func ParseSiteManifest(event *nostr.Event) (*SiteManifest, error) { + sm := &SiteManifest{Event: event} + + switch event.Kind { + case nostr.KindNsiteRoot: + sm.Root = true + case nostr.KindNsiteNamed: + sm.Root = false + for _, tag := range event.Tags { + if len(tag) >= 2 && tag[0] == "d" { + sm.Identifier = tag[1] + break + } + } + if sm.Identifier == "" { + return nil, fmt.Errorf("named site manifest missing d tag") + } + default: + return nil, fmt.Errorf("invalid site manifest kind: %d", event.Kind) + } + + sm.Pubkey = event.PubKey + sm.Paths = make(map[string][32]byte, len(event.Tags)) + + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "path": + var hash [32]byte + if len(tag[2]) != 64 { + return nil, fmt.Errorf("invalid hash '%s' for path '%s'", tag[2], tag[1]) + } + if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(tag[2]), 64)); err != nil { + return nil, fmt.Errorf("invalid hash '%s' for path '%s'", tag[2], tag[1]) + } + sm.Paths[NormalizePath(tag[1])] = hash + case "server": + sm.Servers = append(sm.Servers, tag[1]) + case "title": + sm.Title = tag[1] + case "description": + sm.Description = tag[1] + case "source": + sm.Source = tag[1] + } + } + + return sm, nil +} + +func (sm *SiteManifest) ToEvent(pubkey nostr.PubKey) *nostr.Event { + event := &nostr.Event{ + PubKey: pubkey, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{}, + } + + if sm.Root { + event.Kind = nostr.KindNsiteRoot + } else { + event.Kind = nostr.KindNsiteNamed + event.Tags = append(event.Tags, nostr.Tag{"d", sm.Identifier}) + } + + for path, hash := range sm.Paths { + event.Tags = append(event.Tags, nostr.Tag{"path", NormalizePath(path), hex.EncodeToString(hash[:])}) + } + for _, s := range sm.Servers { + event.Tags = append(event.Tags, nostr.Tag{"server", s}) + } + if sm.Title != "" { + event.Tags = append(event.Tags, nostr.Tag{"title", sm.Title}) + } + if sm.Description != "" { + event.Tags = append(event.Tags, nostr.Tag{"description", sm.Description}) + } + if sm.Source != "" { + event.Tags = append(event.Tags, nostr.Tag{"source", sm.Source}) + } + + return event +} + +//go:inline +func (sm *SiteManifest) GetHashForPath(path string) ([32]byte, bool) { + path = NormalizePath(path) + hash, ok := sm.Paths[path] + return hash, ok +} + +func DecodeSiteURL(label string) (pubkey nostr.PubKey, identifier string, isRoot bool, err error) { + label, _, _ = strings.Cut(label, ".") + + if strings.HasPrefix(label, "npub1") { + _, value, err := nip19.Decode(label) + if err != nil { + return nostr.ZeroPK, "", false, err + } + return value.(nostr.PubKey), "", true, nil + } + + if len(label) < 51 || len(label) > 63 || strings.HasSuffix(label, "-") { + return nostr.ZeroPK, "", false, fmt.Errorf("invalid site label format") + } + + pubkeyB36 := label[:50] + dTag := label[50:] + if !regexp.MustCompile(`^[a-z0-9-]{1,13}$`).MatchString(dTag) { + return nostr.ZeroPK, "", false, fmt.Errorf("invalid dtag format") + } + + pk, err := PubKeyFromBase36(pubkeyB36) + if err != nil { + return nostr.ZeroPK, "", false, err + } + + return pk, dTag, false, nil +}