diff --git a/nip5a/nip5a.go b/nip5a/nip5a.go index d503e16..dd8b679 100644 --- a/nip5a/nip5a.go +++ b/nip5a/nip5a.go @@ -72,12 +72,16 @@ func ParseSiteManifest(event *nostr.Event) (*SiteManifest, error) { } } + if len(sm.Paths) == 0 { + return sm, fmt.Errorf("nsite has zero paths listed") + } + return sm, nil } -func (sm *SiteManifest) ToEvent(pubkey nostr.PubKey) *nostr.Event { - event := &nostr.Event{ - PubKey: pubkey, +func (sm SiteManifest) ToEvent() nostr.Event { + event := nostr.Event{ + PubKey: sm.Pubkey, CreatedAt: nostr.Now(), Tags: nostr.Tags{}, } diff --git a/nip5a/nip5a_test.go b/nip5a/nip5a_test.go new file mode 100644 index 0000000..a31af6a --- /dev/null +++ b/nip5a/nip5a_test.go @@ -0,0 +1,237 @@ +package nip5a + +import ( + "encoding/hex" + "testing" + + "fiatjaf.com/nostr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSiteManifest(t *testing.T) { + pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5") + + t.Run("root site", func(t *testing.T) { + event := &nostr.Event{ + Kind: nostr.KindNsiteRoot, + PubKey: pubkey, + Tags: nostr.Tags{ + {"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"}, + {"path", "/about.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"}, + {"path", "/favicon.ico", "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"}, + {"server", "https://blossom.example.com"}, + {"title", "My Nostr Site"}, + {"description", "A static website hosted on Nostr"}, + {"source", "https://github.com/example/my-nostr-site"}, + }, + } + + sm, err := ParseSiteManifest(event) + require.NoError(t, err) + assert.True(t, sm.Root) + assert.Equal(t, pubkey, sm.Pubkey) + assert.Equal(t, "My Nostr Site", sm.Title) + assert.Equal(t, "A static website hosted on Nostr", sm.Description) + assert.Equal(t, "https://github.com/example/my-nostr-site", sm.Source) + assert.Len(t, sm.Paths, 3) + assert.Len(t, sm.Servers, 1) + assert.Equal(t, "https://blossom.example.com", sm.Servers[0]) + }) + + t.Run("named site", func(t *testing.T) { + event := &nostr.Event{ + Kind: nostr.KindNsiteNamed, + PubKey: pubkey, + Tags: nostr.Tags{ + {"d", "blog"}, + {"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"}, + {"path", "/post.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"}, + {"server", "https://blossom.example.com"}, + {"title", "My Blog"}, + {"description", "A blog hosted on Nostr"}, + {"source", "https://github.com/example/my-nostr-blog"}, + }, + } + + sm, err := ParseSiteManifest(event) + require.NoError(t, err) + assert.False(t, sm.Root) + assert.Equal(t, "blog", sm.Identifier) + assert.Equal(t, pubkey, sm.Pubkey) + assert.Equal(t, "My Blog", sm.Title) + }) + + t.Run("missing d tag on named site", func(t *testing.T) { + event := &nostr.Event{ + Kind: nostr.KindNsiteNamed, + PubKey: pubkey, + Tags: nostr.Tags{ + {"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"}, + }, + } + + _, err := ParseSiteManifest(event) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing d tag") + }) + + t.Run("invalid kind", func(t *testing.T) { + event := &nostr.Event{ + Kind: 1, + PubKey: pubkey, + Tags: nostr.Tags{}, + } + + _, err := ParseSiteManifest(event) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid site manifest kind") + }) +} + +func TestGetHashForPath(t *testing.T) { + pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5") + event := &nostr.Event{ + Kind: nostr.KindNsiteRoot, + PubKey: pubkey, + Tags: nostr.Tags{ + {"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"}, + {"path", "/about.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"}, + }, + } + + sm, err := ParseSiteManifest(event) + require.NoError(t, err) + + hash, ok := sm.GetHashForPath("/index.html") + assert.True(t, ok) + assert.Equal(t, "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99", hex.EncodeToString(hash[:])) + + _, ok = sm.GetHashForPath("/nonexistent.html") + assert.False(t, ok) +} + +func TestNormalizePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/index.html", "/index.html"}, + {"/about.html", "/about.html"}, + {"/blog/", "/blog/index.html"}, + {"/", "/index.html"}, + } + + for _, test := range tests { + result := NormalizePath(test.input) + assert.Equal(t, test.expected, result) + } +} + +func TestPubKeyBase36(t *testing.T) { + pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5") + + b36 := PubKeyToBase36(pubkey) + assert.Len(t, b36, 50) + + decoded, err := PubKeyFromBase36(b36) + require.NoError(t, err) + assert.Equal(t, pubkey, decoded) +} + +func TestDecodeSiteURL(t *testing.T) { + pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5") + + t.Run("npub root site", func(t *testing.T) { + decodedPubkey, identifier, isRoot, err := DecodeSiteURL("npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr") + require.NoError(t, err) + assert.True(t, isRoot) + assert.Equal(t, "", identifier) + assert.Equal(t, decodedPubkey, pubkey) + }) + + t.Run("named site", func(t *testing.T) { + b36 := PubKeyToBase36(pubkey) + label := b36 + "blog" + + decodedPubkey, identifier, isRoot, err := DecodeSiteURL(label) + require.NoError(t, err) + assert.False(t, isRoot) + assert.Equal(t, "blog", identifier) + assert.Equal(t, decodedPubkey, pubkey) + }) + + t.Run("strips domain suffix", func(t *testing.T) { + b36 := PubKeyToBase36(pubkey) + label := b36 + "blog.nsite-host.com" + + _, identifier, _, err := DecodeSiteURL(label) + require.NoError(t, err) + assert.Equal(t, "blog", identifier) + }) + + t.Run("invalid dtag format", func(t *testing.T) { + b36 := PubKeyToBase36(pubkey) + label := b36 + "Blog" + + _, _, _, err := DecodeSiteURL(label) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid dtag format") + }) + + t.Run("label too short", func(t *testing.T) { + _, _, _, err := DecodeSiteURL("npub1") + assert.Error(t, err) + }) + + t.Run("ends with dash", func(t *testing.T) { + b36 := PubKeyToBase36(pubkey) + label := b36 + "blog-" + + _, _, _, err := DecodeSiteURL(label) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid site label format") + }) +} + +func TestSiteManifestToEvent(t *testing.T) { + pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5") + + sm := &SiteManifest{ + Root: true, + Pubkey: pubkey, + Identifier: "", + Paths: map[string][32]byte{ + "/index.html": mustHash("186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"), + }, + Servers: []string{"https://blossom.example.com"}, + Title: "Test Site", + Description: "A test site", + Source: "https://github.com/example/test", + } + + event := sm.ToEvent() + assert.Equal(t, nostr.KindNsiteRoot, event.Kind) + assert.Equal(t, pubkey, event.PubKey) + + sm.Root = false + sm.Identifier = "blog" + event = sm.ToEvent() + assert.Equal(t, nostr.KindNsiteNamed, event.Kind) + found := false + for _, tag := range event.Tags { + if tag[0] == "d" && tag[1] == "blog" { + found = true + break + } + } + assert.True(t, found) +} + +func mustHash(s string) [32]byte { + var h [32]byte + b, _ := hex.DecodeString(s) + copy(h[:], b) + return h +} + diff --git a/nipb0/blossom/download.go b/nipb0/blossom/download.go index e74a507..441af4e 100644 --- a/nipb0/blossom/download.go +++ b/nipb0/blossom/download.go @@ -2,6 +2,7 @@ package blossom import ( "context" + "encoding/hex" "fmt" "io" "net/http" @@ -11,19 +12,17 @@ import ( ) // Download downloads a file from the media server by its hash -func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) { - if !nostr.IsValid32ByteHex(hash) { - return nil, fmt.Errorf("%s is not a valid 32-byte hex string", hash) - } +func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) { + hhash := hex.EncodeToString(hash[:]) - req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil) + req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) { evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"}) - evt.Tags = append(evt.Tags, nostr.Tag{"x", hash}) + evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash}) }) req.Header.Add("Authorization", authHeader) @@ -41,19 +40,17 @@ func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) { } // DownloadToFile downloads a file from the media server and saves it to the specified path -func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error { - if !nostr.IsValid32ByteHex(hash) { - return fmt.Errorf("%s is not a valid 32-byte hex string", hash) - } +func (c *Client) DownloadToFile(ctx context.Context, hash [32]byte, filePath string) error { + hhash := hex.EncodeToString(hash[:]) - req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil) + req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) { evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"}) - evt.Tags = append(evt.Tags, nostr.Tag{"x", hash}) + evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash}) }) req.Header.Add("Authorization", authHeader)