diff --git a/cmd/server/main.go b/cmd/server/main.go index 4bc7540..5ccadd6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -51,7 +51,9 @@ func main() { } // Initialize Nostr client - nostrClient := nostr.NewClient(cfg.Relays.Primary, cfg.Relays.Fallback, cfg.Relays.AdditionalFallback) + profileRelays := cfg.GetProfilesRelays() + contactRelays := cfg.GetContactFormRelays() + nostrClient := nostr.NewClient(cfg.Relays.Feeds, profileRelays, contactRelays) ctx := context.Background() if err := nostrClient.Connect(ctx); err != nil { logger.Warnf("Failed to connect to relays: %v", err) @@ -60,10 +62,15 @@ func main() { // Initialize services // Use standard Nostr kind constants articleKinds := nostr.SupportedArticleKinds() - wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, cfg.Relays.AdditionalFallback, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform) + // Use first profile relay for wiki service (fallback) + wikiRelay := cfg.Relays.Feeds + if len(profileRelays) > 0 { + wikiRelay = profileRelays[0] + } + wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, wikiRelay, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform) feedService := nostr.NewFeedService(nostrClient, nostr.KindNote) issueService := nostr.NewIssueService(nostrClient, nostr.KindIssue, nostr.KindRepoAnnouncement) - ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, "wss://theforest.nostr1.com") + ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, cfg.Relays.Feeds) // Initialize HTML generator htmlGenerator, err := generator.NewHTMLGenerator( @@ -98,7 +105,6 @@ func main() { logger.Info("Generating initial landing page...") initialLandingHTML, err := htmlGenerator.GenerateLandingPage( []generator.WikiPageInfo{}, - []generator.FeedItemInfo{}, nil, // newestBlogItem nil, // newestArticleItem []generator.ArticleItemInfo{}, // allArticleItems diff --git a/config.yaml.example b/config.yaml.example index f3bf092..669cb7a 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -2,14 +2,13 @@ wiki_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8aj blog_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyvhwumn8ghj7enjv4jkccte9eek7anzd96zu6r0wd6qzxmhwden5te0w35x2cmfw3skgetv9ehx7um5wgcjucm0d5q35amnwvaz7tm5dpjkvmmjv4ehgtnwdaehgu339e3k7mgpzpmhxue69uhkummnw3ezumrpdejqzyrhwden5te0dehhxarj9emkjmn9qythwumn8ghj7mn0wd68ytnndamxy6t59e5x7um5qyghwumn8ghj7mn0wd68yv339e3k7mgqy96xsefdva5hgcmfw3skgetv943xcmm89438jttnw3jkcmrp94mz6vggpn2pq" repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqq9xw6t5vd5hgctyv4kqde47kt" relays: - primary: "wss://theforest.nostr1.com" - fallback: "wss://nostr.land" - additional_fallback: "wss://thecitadel.nostr1.com" + feeds: "wss://theforest.nostr1.com" + profiles: "wss://theforest.nostr1.com","wss://nostr.land","wss://thecitadel.nostr1.com" + contactform: "wss://thecitadel.nostr1.com","wss://relay.damus.io","wss://freelay.sovbit.host","wss://relay.primal.net", link_base_url: "https://alexandria.gitcitadel.eu" cache: refresh_interval_minutes: 30 feed: - relay: "wss://theforest.nostr1.com" poll_interval_minutes: 5 max_events: 30 server: diff --git a/internal/config/config.go b/internal/config/config.go index 6eed5a5..37fc969 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) @@ -13,9 +14,9 @@ type Config struct { BlogIndex string `yaml:"blog_index"` RepoAnnouncement string `yaml:"repo_announcement"` // naddr for kind 30617 repo announcement Relays struct { - Primary string `yaml:"primary"` - Fallback string `yaml:"fallback"` - AdditionalFallback string `yaml:"additional_fallback"` + Feeds string `yaml:"feeds"` // Single relay for feeds + Profiles string `yaml:"profiles"` // Comma-separated list of relays for profiles and deletion events + ContactForm string `yaml:"contactform"` // Comma-separated list of relays for contact form } `yaml:"relays"` LinkBaseURL string `yaml:"link_base_url"` Cache struct { @@ -50,14 +51,14 @@ func LoadConfig(path string) (*Config, error) { } // Set defaults - if config.Relays.Primary == "" { - config.Relays.Primary = "wss://theforest.nostr1.com" + if config.Relays.Feeds == "" { + config.Relays.Feeds = "wss://theforest.nostr1.com" } - if config.Relays.Fallback == "" { - config.Relays.Fallback = "wss://nostr.land" + if config.Relays.Profiles == "" { + config.Relays.Profiles = "wss://theforest.nostr1.com,wss://nostr.land,wss://thecitadel.nostr1.com" } - if config.Relays.AdditionalFallback == "" { - config.Relays.AdditionalFallback = "wss://thecitadel.nostr1.com" + if config.Relays.ContactForm == "" { + config.Relays.ContactForm = "wss://thecitadel.nostr1.com,wss://relay.damus.io,wss://freelay.sovbit.host,wss://relay.primal.net" } if config.LinkBaseURL == "" { config.LinkBaseURL = "https://alexandria.gitcitadel.eu" @@ -66,7 +67,7 @@ func LoadConfig(path string) (*Config, error) { config.Cache.RefreshIntervalMinutes = 30 } if config.Feed.Relay == "" { - config.Feed.Relay = "wss://theforest.nostr1.com" + config.Feed.Relay = config.Relays.Feeds } if config.Feed.PollIntervalMinutes == 0 { config.Feed.PollIntervalMinutes = 5 @@ -95,13 +96,49 @@ func LoadConfig(path string) (*Config, error) { return &config, nil } +// GetProfilesRelays parses the comma-separated profiles relay string into a slice +func (c *Config) GetProfilesRelays() []string { + if c.Relays.Profiles == "" { + return []string{} + } + relays := strings.Split(c.Relays.Profiles, ",") + result := make([]string, 0, len(relays)) + for _, relay := range relays { + relay = strings.TrimSpace(relay) + // Remove quotes if present + relay = strings.Trim(relay, "\"'") + if relay != "" { + result = append(result, relay) + } + } + return result +} + +// GetContactFormRelays parses the comma-separated contact form relay string into a slice +func (c *Config) GetContactFormRelays() []string { + if c.Relays.ContactForm == "" { + return []string{} + } + relays := strings.Split(c.Relays.ContactForm, ",") + result := make([]string, 0, len(relays)) + for _, relay := range relays { + relay = strings.TrimSpace(relay) + // Remove quotes if present + relay = strings.Trim(relay, "\"'") + if relay != "" { + result = append(result, relay) + } + } + return result +} + // Validate validates the configuration func (c *Config) Validate() error { if c.WikiIndex == "" { return fmt.Errorf("wiki_index is required") } - if c.Relays.Primary == "" { - return fmt.Errorf("relays.primary is required") + if c.Relays.Feeds == "" { + return fmt.Errorf("relays.feeds is required") } return nil } diff --git a/internal/generator/html.go b/internal/generator/html.go index 6432f0b..5ef3750 100644 --- a/internal/generator/html.go +++ b/internal/generator/html.go @@ -780,6 +780,9 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event } } + // Add contact relays for JavaScript + templateData["ContactRelays"] = g.nostrClient.GetContactRelays() + // Execute base.html which will use the blocks from contact.html var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil { diff --git a/internal/nostr/client.go b/internal/nostr/client.go index 8232eb1..05a4d39 100644 --- a/internal/nostr/client.go +++ b/internal/nostr/client.go @@ -15,34 +15,36 @@ import ( type Client struct { pool *nostr.SimplePool relays []string + feedsRelay string // Relay for feeds + profileRelays []string // Relays for profiles + contactRelays []string // Relays for contact form mu sync.RWMutex 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 relay configuration // 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(feedsRelay string, profileRelays []string, contactRelays []string) *Client { ctx := context.Background() pool := nostr.NewSimplePool(ctx) + // Build relay list: feeds relay first, then profile relays relays := []string{} - if primaryRelay != "" { - relays = append(relays, primaryRelay) - } - if fallbackRelay != "" { - relays = append(relays, fallbackRelay) - } - if additionalFallback != "" { - relays = append(relays, additionalFallback) + if feedsRelay != "" { + relays = append(relays, feedsRelay) } + relays = append(relays, profileRelays...) maxConcurrent := 5 // Limit to 5 concurrent requests to avoid overwhelming relays return &Client{ pool: pool, relays: relays, + feedsRelay: feedsRelay, + profileRelays: profileRelays, + contactRelays: contactRelays, ctx: ctx, requestSem: make(chan struct{}, maxConcurrent), maxConcurrent: maxConcurrent, @@ -223,23 +225,19 @@ func (c *Client) GetRelays() []string { return c.relays } -// GetPrimaryRelay returns the primary relay (theforest) for main event fetching +// GetPrimaryRelay returns the feeds relay for main event fetching func (c *Client) GetPrimaryRelay() string { - if len(c.relays) > 0 { - return c.relays[0] - } - return "" + return c.feedsRelay } -// GetProfileRelays returns fallback relays for profile fetching (excludes primary/theforest) +// GetProfileRelays returns relays for profile fetching func (c *Client) GetProfileRelays() []string { - profileRelays := []string{} - // Skip the first relay (primary/theforest) and use fallback relays - if len(c.relays) > 1 { - profileRelays = append(profileRelays, c.relays[1:]...) - } - // If no fallback relays, return empty (shouldn't happen, but handle gracefully) - return profileRelays + return c.profileRelays +} + +// GetContactRelays returns the relays for contact form submissions +func (c *Client) GetContactRelays() []string { + return c.contactRelays } // GetPool returns the underlying SimplePool (for services that need direct access) @@ -284,10 +282,10 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map return make(map[string]*nostr.Event), nil } - // Fetch kind 5 deletion events from theforest only (primary relay) - primaryRelay := c.GetPrimaryRelay() - if primaryRelay == "" { - return nil, fmt.Errorf("primary relay not configured") + // Fetch kind 5 deletion events from profile relays + profileRelays := c.GetProfileRelays() + if len(profileRelays) == 0 { + return nil, fmt.Errorf("profile relays not configured") } filter := nostr.Filter{ @@ -298,9 +296,10 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map logger.WithFields(map[string]interface{}{ "authors": len(uniqueAuthors), - }).Debug("Fetching deletion events") + "relays": len(profileRelays), + }).Debug("Fetching deletion events from profile relays") - deletionEvents, err := c.FetchEventsFromRelays(ctx, filter, []string{primaryRelay}) + deletionEvents, err := c.FetchEventsFromRelays(ctx, filter, profileRelays) if err != nil { return nil, fmt.Errorf("failed to fetch deletion events: %w", err) } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 4f5df86..9223120 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -287,25 +287,73 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { return } - // Validate event kind (will be validated again in PublishSignedIssue) - // Note: issueKind is stored in issueService, validation happens there + // Validate event kind (should be kind 1 for contact messages) + if req.Event.Kind != 1 { + http.Error(w, fmt.Sprintf("Invalid event kind: expected 1, got %d", req.Event.Kind), http.StatusBadRequest) + return + } - // Publish the signed event + // Verify the event signature + valid, err := req.Event.CheckSignature() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to check signature: %v", err), http.StatusBadRequest) + return + } + if !valid { + http.Error(w, "Invalid event signature", http.StatusBadRequest) + return + } + + // Publish the signed event to contact relays ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - eventID, err := s.issueService.PublishSignedIssue(ctx, req.Event) - if err != nil { - logger.Errorf("Failed to publish signed issue: %v", err) + // Get contact relays + contactRelays := s.nostrClient.GetContactRelays() + + // Publish to contact relays + var lastErr error + var published bool + for _, relayURL := range contactRelays { + relay, err := s.nostrClient.ConnectToRelay(ctx, relayURL) + if err != nil { + logger.WithFields(map[string]interface{}{ + "relay": relayURL, + "error": err, + }).Warn("Failed to connect to contact relay") + lastErr = err + continue + } + + err = relay.Publish(ctx, *req.Event) + relay.Close() + + if err != nil { + logger.WithFields(map[string]interface{}{ + "relay": relayURL, + "error": err, + }).Warn("Failed to publish to contact relay") + lastErr = err + } else { + published = true + logger.WithFields(map[string]interface{}{ + "relay": relayURL, + "event_id": req.Event.ID, + }).Info("Published contact event to relay") + } + } + + if !published { + logger.Errorf("Failed to publish contact event to any relay: %v", lastErr) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, `{"error": "Failed to publish issue: %s"}`, err.Error()) + fmt.Fprintf(w, `{"error": "Failed to publish to any relay: %s"}`, lastErr.Error()) return } // Return success response w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"success": true, "event_id": "%s"}`, eventID) + fmt.Fprintf(w, `{"success": true, "event_id": "%s"}`, req.Event.ID) } // handleStatic serves static files diff --git a/server b/server new file mode 100755 index 0000000..018a3c0 Binary files /dev/null and b/server differ diff --git a/static/css/main.css b/static/css/main.css index fcff88d..68fa38b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1039,6 +1039,85 @@ footer { display: block; } +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + margin: 0; + font-size: 1.5rem; +} + +.modal-close { + background: none; + border: none; + font-size: 2rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-body p { + margin-bottom: 1.5rem; + color: var(--text-secondary); +} + +.modal-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-options .btn { + width: 100%; + justify-content: center; + padding: 1rem; +} + .article-title { font-size: 2.5rem; margin: 0 0 0.5rem 0; diff --git a/static/css/responsive.css b/static/css/responsive.css index c07e870..ac314d6 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -764,6 +764,30 @@ .landing-page .feed-section { margin: 1.5rem 0; } + + /* Modal */ + .modal-content { + width: 95%; + max-width: 95%; + margin: 1rem; + } + + .modal-header { + padding: 1rem; + } + + .modal-header h2 { + font-size: 1.25rem; + } + + .modal-body { + padding: 1rem; + } + + .modal-options .btn { + padding: 0.875rem; + font-size: 0.95rem; + } } /* Tablet styles (768px - 1024px) */ diff --git a/templates/contact.html b/templates/contact.html index 35795e5..5faae51 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -100,15 +100,56 @@ + + + {{if .RepoAnnouncement}} {{end}} + + + diff --git a/templates/feed.html b/templates/feed.html index 1bed205..84603bd 100644 --- a/templates/feed.html +++ b/templates/feed.html @@ -9,7 +9,7 @@

About TheForest Relay

TheForest is a Nostr relay operated by GitCitadel. It provides a reliable, fast, and open relay service for the Nostr protocol.