Browse Source

make filters more resilient and efficient

master
Silberengel 4 weeks ago
parent
commit
c843c4f85d
  1. 11
      config.yaml.example
  2. 44
      internal/generator/html.go
  3. 270
      internal/nostr/client.go
  4. 82
      internal/nostr/feed.go
  5. 7
      internal/nostr/issues.go
  6. 74
      internal/nostr/profile.go
  7. 289
      internal/nostr/wiki.go
  8. 9
      internal/server/handlers.go
  9. 4
      static/css/main.css

11
config.yaml.example

@ -19,14 +19,9 @@ seo:
site_name: "GitCitadel" site_name: "GitCitadel"
site_url: "https://gitcitadel.com" site_url: "https://gitcitadel.com"
default_image: "/static/GitCitadel_Graphic_Landscape.png" default_image: "/static/GitCitadel_Graphic_Landscape.png"
wiki_kinds: wiki_kind: 30818 # Wiki pages
- 30818 # Wiki pages blog_kind: 30041 # Blog articles
blog_kinds: longform_kind: 30023 # Markdown articles
- 30041 # Blog articles
# article_kinds is deprecated, use wiki_kinds and blog_kinds instead
# article_kinds:
# - 30041 # Blog articles
# - 30818 # Wiki pages
index_kind: 30040 # Index event kind (NKBIP-01) index_kind: 30040 # Index event kind (NKBIP-01)
issue_kind: 1621 # Issue event kind issue_kind: 1621 # Issue event kind
repo_announcement_kind: 30617 # Repository announcement kind repo_announcement_kind: 30617 # Repository announcement kind

44
internal/generator/html.go

@ -9,7 +9,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"gitcitadel-online/internal/asciidoc" "gitcitadel-online/internal/asciidoc"
@ -193,42 +192,37 @@ func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaul
}, nil }, nil
} }
// fetchProfilesBatch fetches profiles for multiple pubkeys in parallel // fetchProfilesBatch fetches profiles for multiple pubkeys in a single batched query
func (g *HTMLGenerator) fetchProfilesBatch(ctx context.Context, pubkeys []string) map[string]*nostr.Profile { func (g *HTMLGenerator) fetchProfilesBatch(ctx context.Context, pubkeys []string) map[string]*nostr.Profile {
if g.nostrClient == nil || len(pubkeys) == 0 { if g.nostrClient == nil || len(pubkeys) == 0 {
return make(map[string]*nostr.Profile) return make(map[string]*nostr.Profile)
} }
profiles := make(map[string]*nostr.Profile) // Deduplicate pubkeys
var mu sync.Mutex pubkeySet := make(map[string]bool)
var wg sync.WaitGroup uniquePubkeys := make([]string, 0, len(pubkeys))
for _, pk := range pubkeys {
if pk != "" && !pubkeySet[pk] {
pubkeySet[pk] = true
uniquePubkeys = append(uniquePubkeys, pk)
}
}
if len(uniquePubkeys) == 0 {
return make(map[string]*nostr.Profile)
}
// Create a context with timeout for profile fetching // Create a context with timeout for profile fetching
profileCtx, cancel := context.WithTimeout(ctx, 5*time.Second) profileCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
for _, pubkey := range pubkeys { // Use batch fetch - single query for all profiles
if pubkey == "" { profiles, err := g.nostrClient.FetchProfilesBatch(profileCtx, uniquePubkeys)
continue
}
wg.Add(1)
go func(pk string) {
defer wg.Done()
// Convert pubkey to npub for fetching
npub, err := nostr.PubkeyToNpub(pk)
if err != nil { if err != nil {
return // Log error but return empty map - profiles are optional
} return make(map[string]*nostr.Profile)
profile, err := g.nostrClient.FetchProfile(profileCtx, npub)
if err == nil && profile != nil {
mu.Lock()
profiles[pk] = profile
mu.Unlock()
}
}(pubkey)
} }
wg.Wait()
return profiles return profiles
} }

270
internal/nostr/client.go

@ -11,123 +11,94 @@ import (
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
) )
// Client handles connections to Nostr relays with failover support // Client handles connections to Nostr relays with failover support using go-nostr's SimplePool
type Client struct { type Client struct {
primaryRelay string pool *nostr.SimplePool
fallbackRelay string relays []string
additionalFallback string
mu sync.RWMutex mu sync.RWMutex
lastError error ctx context.Context
requestSem chan struct{} // Semaphore to limit concurrent requests
maxConcurrent int // Maximum concurrent requests
} }
// NewClient creates a new Nostr client with primary, fallback, and additional fallback relays // NewClient creates a new Nostr client with primary, fallback, and additional fallback relays
// Uses go-nostr's SimplePool for connection management and parallel queries
// Limits concurrent requests to prevent overwhelming relays (default: 5 concurrent requests)
func NewClient(primaryRelay, fallbackRelay, additionalFallback string) *Client { func NewClient(primaryRelay, fallbackRelay, additionalFallback string) *Client {
ctx := context.Background()
pool := nostr.NewSimplePool(ctx)
relays := []string{}
if primaryRelay != "" {
relays = append(relays, primaryRelay)
}
if fallbackRelay != "" {
relays = append(relays, fallbackRelay)
}
if additionalFallback != "" {
relays = append(relays, additionalFallback)
}
maxConcurrent := 5 // Limit to 5 concurrent requests to avoid overwhelming relays
return &Client{ return &Client{
primaryRelay: primaryRelay, pool: pool,
fallbackRelay: fallbackRelay, relays: relays,
additionalFallback: additionalFallback, ctx: ctx,
requestSem: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent,
} }
} }
// Connect connects to the relays (no-op for now, connections happen on query) // Connect connects to the relays (no-op, SimplePool handles connections lazily)
func (c *Client) Connect(ctx context.Context) error { func (c *Client) Connect(ctx context.Context) error {
// Connections are established lazily when querying // SimplePool handles connections lazily when querying
return nil return nil
} }
// ConnectToRelay connects to a single relay (exported for use by services) // ConnectToRelay connects to a single relay using SimplePool (exported for use by services)
func (c *Client) ConnectToRelay(ctx context.Context, url string) (*nostr.Relay, error) { func (c *Client) ConnectToRelay(ctx context.Context, url string) (*nostr.Relay, error) {
relay, err := nostr.RelayConnect(ctx, url) // Use SimplePool's EnsureRelay which handles connection pooling and reuse
if err != nil { return c.pool.EnsureRelay(url)
return nil, err
}
return relay, nil
} }
// connectToRelay connects to a single relay (deprecated, use ConnectToRelay) // FetchEvent fetches a single event by querying all relays in parallel using SimplePool
func (c *Client) connectToRelay(ctx context.Context, url string) (*nostr.Relay, error) {
return c.ConnectToRelay(ctx, url)
}
// FetchEvent fetches a single event by querying all three relays in parallel with 10-second timeout
// Returns the newest event (highest created_at) if multiple events are found // Returns the newest event (highest created_at) if multiple events are found
// Rate-limited to prevent overwhelming relays
func (c *Client) FetchEvent(ctx context.Context, filter nostr.Filter) (*nostr.Event, error) { func (c *Client) FetchEvent(ctx context.Context, filter nostr.Filter) (*nostr.Event, error) {
// Create context with 10-second timeout // Acquire semaphore to limit concurrent requests
queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second) c.requestSem <- struct{}{}
defer cancel() defer func() { <-c.requestSem }()
// Query all three relays in parallel // Create context with 30-second timeout
relays := []string{c.primaryRelay, c.fallbackRelay, c.additionalFallback} queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
type result struct { defer cancel()
relay string
events []*nostr.Event
err error
}
results := make(chan result, len(relays))
var wg sync.WaitGroup
for _, relayURL := range relays {
if relayURL == "" {
continue
}
wg.Add(1)
go func(url string) {
defer wg.Done()
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"relay": url, "relays": c.relays,
"kinds": filter.Kinds, "kinds": filter.Kinds,
"authors": filter.Authors, "authors": filter.Authors,
"ids": filter.IDs, "ids": filter.IDs,
"tags": filter.Tags, "tags": filter.Tags,
}).Debug("Querying relay") }).Debug("Querying relays using SimplePool")
relay, err := c.connectToRelay(queryCtx, url)
if err != nil {
logger.WithField("relay", url).Debugf("Connection failed: %v", err)
results <- result{relay: url, events: nil, err: err}
return
}
defer relay.Close()
events, err := relay.QuerySync(queryCtx, filter)
if err != nil {
logger.WithField("relay", url).Debugf("Query failed: %v", err)
results <- result{relay: url, events: nil, err: err}
return
}
logger.WithFields(map[string]interface{}{
"relay": url,
"events": len(events),
}).Debug("Relay returned events")
results <- result{relay: url, events: events, err: nil}
}(relayURL)
}
// Wait for all queries to complete // Use SimplePool's SubManyEose to query all relays in parallel
go func() { // It automatically handles connection pooling, failover, and deduplication
wg.Wait() eventChan := c.pool.SubManyEose(queryCtx, c.relays, nostr.Filters{filter})
close(results)
}()
// Collect all events from all relays // Collect all events from all relays
var allEvents []*nostr.Event var allEvents []*nostr.Event
var lastErr error for incomingEvent := range eventChan {
for res := range results { if incomingEvent.Event != nil {
if res.err != nil { allEvents = append(allEvents, incomingEvent.Event)
lastErr = res.err logger.WithFields(map[string]interface{}{
continue "relay": incomingEvent.Relay.URL,
} "event_id": incomingEvent.Event.ID,
if len(res.events) > 0 { "created_at": incomingEvent.Event.CreatedAt,
allEvents = append(allEvents, res.events...) }).Debug("Received event from relay")
} }
} }
if len(allEvents) == 0 { if len(allEvents) == 0 {
if lastErr != nil {
return nil, fmt.Errorf("event not found from any relay: %w", lastErr)
}
return nil, fmt.Errorf("event not found from any relay") return nil, fmt.Errorf("event not found from any relay")
} }
@ -146,89 +117,56 @@ func (c *Client) FetchEvent(ctx context.Context, filter nostr.Filter) (*nostr.Ev
return newest, nil return newest, nil
} }
// FetchEvents fetches multiple events by querying all three relays in parallel with 10-second timeout // FetchEvents fetches multiple events by querying all relays in parallel using SimplePool
// Returns deduplicated events, keeping the newest version of each event // Returns deduplicated events, keeping the newest version of each event
// Rate-limited to prevent overwhelming relays
func (c *Client) FetchEvents(ctx context.Context, filter nostr.Filter) ([]*nostr.Event, error) { func (c *Client) FetchEvents(ctx context.Context, filter nostr.Filter) ([]*nostr.Event, error) {
// Create context with 10-second timeout return c.FetchEventsBatch(ctx, []nostr.Filter{filter})
queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Query all three relays in parallel
relays := []string{c.primaryRelay, c.fallbackRelay, c.additionalFallback}
type result struct {
relay string
events []*nostr.Event
err error
} }
results := make(chan result, len(relays)) // FetchEventsBatch fetches multiple events using multiple filters in a single batched query
var wg sync.WaitGroup // This is more efficient than calling FetchEvents multiple times
// Returns deduplicated events, keeping the newest version of each event
for _, relayURL := range relays { // Rate-limited to prevent overwhelming relays
if relayURL == "" { func (c *Client) FetchEventsBatch(ctx context.Context, filters []nostr.Filter) ([]*nostr.Event, error) {
continue if len(filters) == 0 {
} return nil, fmt.Errorf("no filters provided")
wg.Add(1)
go func(url string) {
defer wg.Done()
logger.WithFields(map[string]interface{}{
"relay": url,
"kinds": filter.Kinds,
"authors": filter.Authors,
"ids": filter.IDs,
"tags": filter.Tags,
"limit": filter.Limit,
}).Debug("Querying relay")
relay, err := c.connectToRelay(queryCtx, url)
if err != nil {
logger.WithField("relay", url).Debugf("Connection failed: %v", err)
results <- result{relay: url, events: nil, err: err}
return
} }
defer relay.Close()
events, err := relay.QuerySync(queryCtx, filter) // Acquire semaphore to limit concurrent requests
if err != nil { c.requestSem <- struct{}{}
logger.WithField("relay", url).Debugf("Query failed: %v", err) defer func() { <-c.requestSem }()
results <- result{relay: url, events: nil, err: err}
return // Create context with 30-second timeout
} queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"relay": url, "relays": c.relays,
"events": len(events), "filters": len(filters),
}).Debug("Relay returned events") }).Debug("Querying relays using SimplePool with batched filters")
results <- result{relay: url, events: events, err: nil}
}(relayURL)
}
// Wait for all queries to complete // Use SimplePool's SubManyEose to query all relays in parallel with all filters
go func() { // It automatically handles connection pooling, failover, and deduplication
wg.Wait() eventChan := c.pool.SubManyEose(queryCtx, c.relays, nostr.Filters(filters))
close(results)
}()
// Collect all events from all relays, deduplicating by ID and keeping newest // Collect all events from all relays, deduplicating by ID and keeping newest
eventMap := make(map[string]*nostr.Event) eventMap := make(map[string]*nostr.Event)
var lastErr error for incomingEvent := range eventChan {
for res := range results { if incomingEvent.Event != nil {
if res.err != nil { existing, exists := eventMap[incomingEvent.Event.ID]
lastErr = res.err if !exists || incomingEvent.Event.CreatedAt > existing.CreatedAt {
continue eventMap[incomingEvent.Event.ID] = incomingEvent.Event
} logger.WithFields(map[string]interface{}{
for _, event := range res.events { "relay": incomingEvent.Relay.URL,
existing, exists := eventMap[event.ID] "event_id": incomingEvent.Event.ID,
if !exists || event.CreatedAt > existing.CreatedAt { "created_at": incomingEvent.Event.CreatedAt,
eventMap[event.ID] = event }).Debug("Received event from relay")
} }
} }
} }
if len(eventMap) == 0 { if len(eventMap) == 0 {
if lastErr != nil {
return nil, fmt.Errorf("no events found from any relay: %w", lastErr)
}
return nil, fmt.Errorf("no events found from any relay") return nil, fmt.Errorf("no events found from any relay")
} }
@ -259,36 +197,20 @@ func (c *Client) FetchEventsByKind(ctx context.Context, kind int, limit int) ([]
return c.FetchEvents(ctx, filter) return c.FetchEvents(ctx, filter)
} }
// Close closes all relay connections (no-op for lazy connections) // Close closes all relay connections in the pool
func (c *Client) Close() { func (c *Client) Close() {
// Connections are closed after each query, so nothing to do here // SimplePool manages connections, but we can close individual relays if needed
} // The pool will handle cleanup when context is cancelled
// GetLastError returns the last error encountered
func (c *Client) GetLastError() error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastError
} }
// IsConnected checks if at least one relay is connected (always true for lazy connections) // GetRelays returns all configured relay URLs
func (c *Client) IsConnected() bool {
return true // We connect on-demand
}
// GetRelays returns all configured relay URLs (primary, fallback, additional fallback)
func (c *Client) GetRelays() []string { func (c *Client) GetRelays() []string {
relays := []string{} return c.relays
if c.primaryRelay != "" {
relays = append(relays, c.primaryRelay)
} }
if c.fallbackRelay != "" {
relays = append(relays, c.fallbackRelay) // GetPool returns the underlying SimplePool (for services that need direct access)
} func (c *Client) GetPool() *nostr.SimplePool {
if c.additionalFallback != "" { return c.pool
relays = append(relays, c.additionalFallback)
}
return relays
} }
// HealthCheck performs a health check on the relays // HealthCheck performs a health check on the relays

82
internal/nostr/feed.go

@ -24,7 +24,7 @@ func NewFeedService(client *Client, feedKind int) *FeedService {
} }
} }
// FetchFeedItems fetches recent feed events from the configured feed relay only, with retries // FetchFeedItems fetches recent feed events from the configured feed relay using SimplePool
func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, maxEvents int) ([]FeedItem, error) { func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, maxEvents int) ([]FeedItem, error) {
if feedRelay == "" { if feedRelay == "" {
return nil, fmt.Errorf("feed relay not configured") return nil, fmt.Errorf("feed relay not configured")
@ -36,77 +36,45 @@ func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, max
} }
logFilter(filter, fmt.Sprintf("feed (kind %d)", fs.feedKind)) logFilter(filter, fmt.Sprintf("feed (kind %d)", fs.feedKind))
const maxRetries = 3 // Create context with 30-second timeout
var lastErr error queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Retry up to 3 times for the configured feed relay only
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"relay": feedRelay, "relay": feedRelay,
"attempt": attempt, "kind": fs.feedKind,
"max": maxRetries, "limit": maxEvents,
}).Debug("Retrying feed relay") }).Debug("Fetching feed items using SimplePool")
// Wait a bit before retrying (exponential backoff: 1s, 2s)
waitTime := time.Duration(attempt-1) * time.Second // Use the client's pool to query the feed relay
time.Sleep(waitTime) // SimplePool handles connection pooling and reuse automatically
} eventChan := fs.client.GetPool().SubManyEose(queryCtx, []string{feedRelay}, nostr.Filters{filter})
// Create a context with timeout for this attempt // Collect events and convert to feed items
attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) items := make([]FeedItem, 0, maxEvents)
for incomingEvent := range eventChan {
// Connect to the relay if incomingEvent.Event != nil {
relayConn, err := fs.client.ConnectToRelay(attemptCtx, feedRelay)
if err != nil {
logger.WithFields(map[string]interface{}{
"relay": feedRelay,
"attempt": attempt,
"max": maxRetries,
}).Debugf("Failed to connect to feed relay: %v", err)
lastErr = err
cancel()
continue // Try next attempt
}
// Try to fetch events
events, err := relayConn.QuerySync(attemptCtx, filter)
relayConn.Close()
cancel()
if err != nil {
logger.WithFields(map[string]interface{}{
"relay": feedRelay,
"attempt": attempt,
"max": maxRetries,
}).Debugf("Failed to fetch feed events: %v", err)
lastErr = err
continue // Try next attempt
}
// Success! Convert events to feed items
items := make([]FeedItem, 0, len(events))
for _, event := range events {
item := FeedItem{ item := FeedItem{
Author: event.PubKey, Author: incomingEvent.Event.PubKey,
Content: event.Content, Content: incomingEvent.Event.Content,
Time: time.Unix(int64(event.CreatedAt), 0), Time: time.Unix(int64(incomingEvent.Event.CreatedAt), 0),
Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", event.ID), Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", incomingEvent.Event.ID),
} }
items = append(items, item) items = append(items, item)
logger.WithFields(map[string]interface{}{
"relay": incomingEvent.Relay.URL,
"event_id": incomingEvent.Event.ID,
}).Debug("Received feed event")
}
} }
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"relay": feedRelay, "relay": feedRelay,
"items": len(items), "items": len(items),
"attempt": attempt,
}).Info("Successfully fetched feed items") }).Info("Successfully fetched feed items")
return items, nil return items, nil
} }
// All retries failed
return nil, fmt.Errorf("failed to fetch feed from %s after %d retries (last error: %w)", feedRelay, maxRetries, lastErr)
}
// FeedItem represents a feed item // FeedItem represents a feed item
type FeedItem struct { type FeedItem struct {
Author string Author string

7
internal/nostr/issues.go

@ -168,7 +168,10 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA
relays := repoAnnouncement.Relays relays := repoAnnouncement.Relays
if len(relays) == 0 { if len(relays) == 0 {
// Fallback to default relays if none specified // Fallback to default relays if none specified
relays = []string{s.client.primaryRelay, s.client.fallbackRelay} clientRelays := s.client.GetRelays()
if len(clientRelays) > 0 {
relays = clientRelays
}
} }
// Publish to relays // Publish to relays
@ -217,7 +220,7 @@ func (s *IssueService) PublishSignedIssue(ctx context.Context, signedEvent *nost
// Determine which relays to publish to // Determine which relays to publish to
// Try to extract relays from the event tags or use defaults // Try to extract relays from the event tags or use defaults
relays := []string{s.client.primaryRelay, s.client.fallbackRelay} relays := s.client.GetRelays()
// Look for relay hints in tags // Look for relay hints in tags
for _, tag := range signedEvent.Tags { for _, tag := range signedEvent.Tags {

74
internal/nostr/profile.go

@ -73,31 +73,81 @@ func (c *Client) FetchProfile(ctx context.Context, npub string) (*Profile, error
return nil, fmt.Errorf("failed to parse npub: unexpected type") return nil, fmt.Errorf("failed to parse npub: unexpected type")
} }
// Create filter for kind 0 profile event profiles, err := c.FetchProfilesBatch(ctx, []string{pubkey})
if err != nil {
return nil, err
}
profile, exists := profiles[pubkey]
if !exists {
return nil, fmt.Errorf("profile not found for pubkey")
}
return profile, nil
}
// FetchProfilesBatch fetches kind 0 profile events for multiple pubkeys in a single query
// Returns a map of pubkey -> Profile
func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[string]*Profile, error) {
if len(pubkeys) == 0 {
return make(map[string]*Profile), nil
}
// Deduplicate pubkeys
pubkeySet := make(map[string]bool)
uniquePubkeys := make([]string, 0, len(pubkeys))
for _, pk := range pubkeys {
if pk != "" && !pubkeySet[pk] {
pubkeySet[pk] = true
uniquePubkeys = append(uniquePubkeys, pk)
}
}
if len(uniquePubkeys) == 0 {
return make(map[string]*Profile), nil
}
// Query ALL kind 0 events for these authors in one batch
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []int{0}, Kinds: []int{0},
Authors: []string{pubkey}, Authors: uniquePubkeys,
Limit: 1, Limit: len(uniquePubkeys), // One profile per author
} }
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"npub": npub, "pubkeys": len(uniquePubkeys),
"pubkey": pubkey, }).Debug("Batch fetching profiles")
}).Debug("Fetching profile")
// Fetch the event // Fetch all profile events
event, err := c.FetchEvent(ctx, filter) events, err := c.FetchEvents(ctx, filter)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch profile event: %w", err) return nil, fmt.Errorf("failed to fetch profile events: %w", err)
} }
// Parse the profile // Parse profiles and map by pubkey
profiles := make(map[string]*Profile)
for _, event := range events {
profile, err := ParseProfile(event) profile, err := ParseProfile(event)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse profile: %w", err) logger.WithFields(map[string]interface{}{
"pubkey": event.PubKey,
"event_id": event.ID,
}).Warnf("Error parsing profile: %v", err)
continue
}
// Keep the newest profile if we have multiple for the same pubkey
existing, exists := profiles[event.PubKey]
if !exists || event.CreatedAt > existing.Event.CreatedAt {
profiles[event.PubKey] = profile
}
} }
return profile, nil logger.WithFields(map[string]interface{}{
"requested": len(uniquePubkeys),
"fetched": len(profiles),
}).Debug("Batch profile fetch completed")
return profiles, nil
} }
// ParseProfile parses a kind 0 profile event // ParseProfile parses a kind 0 profile event

289
internal/nostr/wiki.go

@ -71,127 +71,90 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
} }
// FetchWikiEvents fetches all wiki events referenced in an index // FetchWikiEvents fetches all wiki events referenced in an index
// Queries by kind only, then filters locally and batch-fetches profiles
func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) { func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) {
var wikiEvents []*WikiEvent // Build a map of expected items (kind:pubkey:dtag) for fast lookup
expectedItems := make(map[string]IndexItem)
for _, item := range index.Items { for _, item := range index.Items {
if item.Kind != ws.wikiKind { if item.Kind == ws.wikiKind {
continue // Skip non-wiki items key := fmt.Sprintf("%d:%s:%s", item.Kind, item.Pubkey, item.DTag)
expectedItems[key] = item
}
}
if len(expectedItems) == 0 {
return []*WikiEvent{}, nil
} }
// Create filter for this wiki event // Query ALL events of this kind - simple query by kind only
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []int{ws.wikiKind}, Kinds: []int{ws.wikiKind},
Authors: []string{item.Pubkey}, Limit: 10000, // Reasonable limit
Tags: map[string][]string{
"d": {item.DTag},
},
} }
// If event ID is specified, use it for more reliable fetching
if item.EventID != "" {
filter.IDs = []string{item.EventID}
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"event_id": item.EventID[:16] + "...", "kind": ws.wikiKind,
"dtag": item.DTag, "items": len(expectedItems),
}).Debug("Using event ID for wiki event") }).Debug("Querying all events of kind from relays")
}
logFilter(filter, fmt.Sprintf("wiki event %s", item.DTag))
// Use relay hint if available, otherwise use default client relays // Fetch all events of this kind
var event *nostr.Event allEvents, err := ws.client.FetchEvents(ctx, filter)
var err error if err != nil {
if item.RelayHint != "" { logger.WithField("error", err).Warn("Failed to fetch events by kind")
// Connect to the relay hint return nil, err
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
}).Debug("Trying relay hint")
relay, relayErr := ws.client.ConnectToRelay(ctx, item.RelayHint)
if relayErr == nil {
events, queryErr := relay.QuerySync(ctx, filter)
relay.Close()
if queryErr == nil {
if len(events) > 0 {
event = events[0]
logger.WithFields(map[string]interface{}{
"dtag": item.DTag,
"relay_hint": item.RelayHint,
}).Debug("Successfully fetched from relay hint")
} else {
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
"has_event_id": item.EventID != "",
}).Debug("Relay hint returned 0 events")
}
} else {
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
}).Debugf("Error querying relay hint: %v", queryErr)
} }
} else {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint, "fetched": len(allEvents),
"dtag": item.DTag, "expected": len(expectedItems),
}).Debugf("Error connecting to relay hint: %v", relayErr) }).Debug("Fetched events, filtering locally")
// Filter events locally by matching against index items
eventMap := make(map[string]*nostr.Event) // Map by kind:pubkey:dtag
for _, event := range allEvents {
// Extract d-tag from event
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
} }
} }
// Fallback to default client relays if relay hint failed or wasn't provided if dTag == "" {
// Client now queries all three relays automatically continue // Skip events without d-tag
if event == nil {
logger.WithField("dtag", item.DTag).Debug("Querying all relays")
event, err = ws.client.FetchEvent(ctx, filter)
if err != nil {
// If still not found and event ID was specified, try without event ID to get latest replaceable event
if item.EventID != "" {
logger.WithField("dtag", item.DTag).Debug("Trying without event ID to get latest replaceable event")
filterWithoutID := nostr.Filter{
Kinds: []int{ws.wikiKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
logFilter(filterWithoutID, fmt.Sprintf("wiki event %s (without event ID)", item.DTag))
event, err = ws.client.FetchEvent(ctx, filterWithoutID)
if err == nil {
logger.WithField("dtag", item.DTag).Debug("Successfully fetched latest from all relays")
}
}
} else {
logger.WithField("dtag", item.DTag).Debug("Successfully fetched from all relays")
} }
if event == nil { key := fmt.Sprintf("%d:%s:%s", event.Kind, event.PubKey, dTag)
// Log error but continue with other events if _, expected := expectedItems[key]; expected {
logger.WithFields(map[string]interface{}{ // Keep the newest version if we have multiple
"dtag": item.DTag, existing, exists := eventMap[key]
"pubkey": item.Pubkey, if !exists || event.CreatedAt > existing.CreatedAt {
"has_event_id": item.EventID != "", eventMap[key] = event
}).Warnf("Error fetching wiki event: %v", err) }
continue
} }
} }
// Parse the wiki event // Convert matched events to wiki events
var wikiEvents []*WikiEvent
for key, event := range eventMap {
wiki, err := ParseWikiEvent(event, ws.wikiKind) wiki, err := ParseWikiEvent(event, ws.wikiKind)
if err != nil { if err != nil {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"dtag": item.DTag, "key": key,
}).Warnf("Error parsing wiki event: %v", err) }).Warnf("Error parsing wiki event: %v", err)
continue continue
} }
wikiEvents = append(wikiEvents, wiki) wikiEvents = append(wikiEvents, wiki)
} }
logger.WithFields(map[string]interface{}{
"matched": len(wikiEvents),
"expected": len(expectedItems),
}).Debug("Matched wiki events")
if len(wikiEvents) == 0 && len(index.Items) > 0 { if len(wikiEvents) == 0 && len(index.Items) > 0 {
logger.WithField("items", len(index.Items)).Warn("No wiki events could be fetched from index items") logger.WithField("items", len(index.Items)).Warn("No wiki events matched from fetched events")
// Return empty slice instead of error to allow landing page generation
} }
return wikiEvents, nil return wikiEvents, nil
@ -204,6 +167,7 @@ func (ws *WikiService) GetBlogKind() int {
// FetchIndexEvents fetches all events of a specific kind referenced in an index // FetchIndexEvents fetches all events of a specific kind referenced in an index
// Only supports article kinds configured in the service // Only supports article kinds configured in the service
// Queries by kind only, then filters locally
func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent, targetKind int) ([]*nostr.Event, error) { func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent, targetKind int) ([]*nostr.Event, error) {
// Check if the target kind is in the allowed article kinds // Check if the target kind is in the allowed article kinds
allowed := false allowed := false
@ -217,124 +181,85 @@ func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent,
return nil, fmt.Errorf("unsupported event kind: %d (only %v are supported)", targetKind, ws.articleKinds) return nil, fmt.Errorf("unsupported event kind: %d (only %v are supported)", targetKind, ws.articleKinds)
} }
var events []*nostr.Event // Build a map of expected items (kind:pubkey:dtag) for fast lookup
expectedItems := make(map[string]IndexItem)
for _, item := range index.Items { for _, item := range index.Items {
// Only process items of the target kind if item.Kind == targetKind {
if item.Kind != targetKind { key := fmt.Sprintf("%d:%s:%s", item.Kind, item.Pubkey, item.DTag)
continue // Skip items that don't match the target kind expectedItems[key] = item
} }
// Create filter for this event
filter := nostr.Filter{
Kinds: []int{targetKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
} }
// If event ID is specified, use it for more reliable fetching if len(expectedItems) == 0 {
if item.EventID != "" { return []*nostr.Event{}, nil
filter.IDs = []string{item.EventID}
} }
logFilter(filter, fmt.Sprintf("index event kind %d %s", targetKind, item.DTag)) // Query ALL events of this kind - simple query by kind only
filter := nostr.Filter{
// Use relay hint if available, otherwise use default client relays Kinds: []int{targetKind},
var event *nostr.Event Limit: 10000, // Reasonable limit
var err error
if item.RelayHint != "" {
// Connect to the relay hint
relay, relayErr := ws.client.ConnectToRelay(ctx, item.RelayHint)
if relayErr == nil {
relayEvents, queryErr := relay.QuerySync(ctx, filter)
relay.Close()
if queryErr == nil {
if len(relayEvents) > 0 {
event = relayEvents[0]
} else {
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
"kind": targetKind,
"has_event_id": item.EventID != "",
}).Debug("Relay hint returned 0 events")
}
} else {
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
"kind": targetKind,
}).Debugf("Error querying relay hint: %v", queryErr)
}
} else {
logger.WithFields(map[string]interface{}{
"relay_hint": item.RelayHint,
"dtag": item.DTag,
"kind": targetKind,
}).Debugf("Error connecting to relay hint: %v", relayErr)
}
} }
// Fallback to default client relays if relay hint failed or wasn't provided
// Client now queries all three relays automatically
if event == nil {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"dtag": item.DTag,
"kind": targetKind, "kind": targetKind,
}).Debug("Querying all relays") "items": len(expectedItems),
event, err = ws.client.FetchEvent(ctx, filter) }).Debug("Querying all events of kind from relays")
// Fetch all events of this kind
allEvents, err := ws.client.FetchEvents(ctx, filter)
if err != nil { if err != nil {
// If still not found and event ID was specified, try without event ID to get latest replaceable event logger.WithField("error", err).Warn("Failed to fetch events by kind")
if item.EventID != "" { return nil, err
logger.WithFields(map[string]interface{}{
"dtag": item.DTag,
"kind": targetKind,
}).Debug("Trying without event ID to get latest replaceable event")
filterWithoutID := nostr.Filter{
Kinds: []int{targetKind},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
} }
logFilter(filterWithoutID, fmt.Sprintf("index event kind %d %s (without event ID)", targetKind, item.DTag))
event, err = ws.client.FetchEvent(ctx, filterWithoutID)
if err == nil {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"dtag": item.DTag, "fetched": len(allEvents),
"kind": targetKind, "expected": len(expectedItems),
}).Debug("Successfully fetched latest from all relays") }).Debug("Fetched events, filtering locally")
// Filter events locally by matching against index items
eventMap := make(map[string]*nostr.Event) // Map by kind:pubkey:dtag
for _, event := range allEvents {
// Extract d-tag from event
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
} }
} }
} else {
logger.WithFields(map[string]interface{}{ if dTag == "" {
"dtag": item.DTag, continue // Skip events without d-tag
"kind": targetKind,
}).Debug("Successfully fetched from all relays")
} }
if event == nil { key := fmt.Sprintf("%d:%s:%s", event.Kind, event.PubKey, dTag)
// Log error but continue with other events if _, expected := expectedItems[key]; expected {
logger.WithFields(map[string]interface{}{ // Keep the newest version if we have multiple
"dtag": item.DTag, existing, exists := eventMap[key]
"kind": targetKind, if !exists || event.CreatedAt > existing.CreatedAt {
"pubkey": item.Pubkey, eventMap[key] = event
"has_event_id": item.EventID != "", }
}).Warnf("Error fetching event: %v", err)
continue
} }
} }
// Convert to result slice
events := make([]*nostr.Event, 0, len(eventMap))
for _, event := range eventMap {
events = append(events, event) events = append(events, event)
} }
logger.WithFields(map[string]interface{}{
"matched": len(events),
"expected": len(expectedItems),
"kind": targetKind,
}).Debug("Matched index events")
if len(events) == 0 && len(index.Items) > 0 { if len(events) == 0 && len(index.Items) > 0 {
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"kind": targetKind, "kind": targetKind,
"items": len(index.Items), "items": len(index.Items),
}).Warn("No events could be fetched from index items") }).Warn("No events matched from fetched events")
} }
return events, nil return events, nil

9
internal/server/handlers.go

@ -21,6 +21,9 @@ func (s *Server) setupRoutes(mux *http.ServeMux) {
// Static files // Static files
mux.HandleFunc("/static/", s.handleStatic) mux.HandleFunc("/static/", s.handleStatic)
// Favicon
mux.HandleFunc("/favicon.ico", s.handleFavicon)
// Main routes // Main routes
mux.HandleFunc("/", s.handleLanding) mux.HandleFunc("/", s.handleLanding)
mux.HandleFunc("/wiki/", s.handleWiki) mux.HandleFunc("/wiki/", s.handleWiki)
@ -283,6 +286,12 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r) http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
} }
// handleFavicon serves the favicon
func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
// Serve the SVG icon as favicon
http.ServeFile(w, r, "./static/GitCitadel_Icon_Black.svg")
}
// handleHealth handles health check requests // handleHealth handles health check requests
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if s.cache.Size() == 0 { if s.cache.Size() == 0 {

4
static/css/main.css

@ -77,12 +77,16 @@ header {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin: 0; margin: 0;
padding: 0; padding: 0;
will-change: transform; /* Optimize for fixed positioning */
} }
.navbar { .navbar {
padding: 1rem 0; padding: 1rem 0;
width: 100%; width: 100%;
margin: 0; margin: 0;
min-height: 64px; /* Ensure minimum height for header */
display: flex;
align-items: center;
} }
.nav-container { .nav-container {

Loading…
Cancel
Save