package server import ( "context" "encoding/json" "fmt" "net/http" "path/filepath" "strings" "time" gonostr "github.com/nbd-wtf/go-nostr" "gitcitadel-online/internal/cache" "gitcitadel-online/internal/generator" "gitcitadel-online/internal/logger" "gitcitadel-online/internal/nostr" ) // setupRoutes sets up all HTTP routes func (s *Server) setupRoutes(mux *http.ServeMux) { // Static files mux.HandleFunc("/static/", s.handleStatic) // Media cache mux.HandleFunc("/cache/media/", s.handleMediaCache) // Favicon mux.HandleFunc("/favicon.ico", s.handleFavicon) // Main routes mux.HandleFunc("/", s.handleLanding) mux.HandleFunc("/wiki/", s.handleWiki) mux.HandleFunc("/blog", s.handleBlog) mux.HandleFunc("/articles", s.handleArticles) mux.HandleFunc("/ebooks", s.handleEBooks) mux.HandleFunc("/contact", s.handleContact) mux.HandleFunc("/feed", s.handleFeed) // Health and metrics mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/metrics", s.handleMetrics) // SEO mux.HandleFunc("/sitemap.xml", s.handleSitemap) mux.HandleFunc("/robots.txt", s.handleRobots) // API endpoints mux.HandleFunc("/api/contact", s.handleContactAPI) } // handleLanding handles the landing page func (s *Server) handleLanding(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { s.handle404(w, r) return } page, exists := s.cache.Get("/") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) } // handleWiki handles wiki article pages and wiki index func (s *Server) handleWiki(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Handle wiki index page (/wiki or /wiki/) if path == "/wiki" || path == "/wiki/" { page, exists := s.cache.Get("/wiki") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) return } // Handle individual wiki pages (/wiki/{dTag}) page, exists := s.cache.Get(path) if !exists { s.handle404(w, r) return } s.servePage(w, r, page) } // handleBlog handles the blog page func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) { page, exists := s.cache.Get("/blog") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) } // handleArticles handles the articles page func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { page, exists := s.cache.Get("/articles") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) } // handleEBooks handles the e-books listing page func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) { page, exists := s.cache.Get("/ebooks") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) } // handleFeed handles the Feed page func (s *Server) handleFeed(w http.ResponseWriter, r *http.Request) { page, exists := s.cache.Get("/feed") if !exists { http.Error(w, "Page not ready", http.StatusServiceUnavailable) return } s.servePage(w, r, page) } // handleContact handles the contact form (GET and POST) func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { // Fetch repo announcement for embedding in page ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var repoAnnouncement *nostr.RepoAnnouncement var err error if s.repoAnnouncement != "" { repoAnnouncement, err = s.issueService.FetchRepoAnnouncement(ctx, s.repoAnnouncement) if err != nil { logger.Warnf("Failed to fetch repo announcement for contact page: %v", err) // Continue without repo announcement - form will show error } } // Fetch profile for npub var profile *nostr.Profile npub := "npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" if s.nostrClient != nil { profile, err = s.nostrClient.FetchProfile(ctx, npub) if err != nil { logger.Warnf("Failed to fetch profile for contact page: %v", err) // Continue without profile - not critical } } // Render the contact form (feed items not needed - only on landing page) html, err := s.htmlGenerator.GenerateContactPage(false, "", "", nil, repoAnnouncement, []generator.FeedItemInfo{}, profile) if err != nil { http.Error(w, "Failed to generate contact page", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse form data if err := r.ParseForm(); err != nil { html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to parse form data", "", nil, nil, []generator.FeedItemInfo{}, nil) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } subject := strings.TrimSpace(r.FormValue("subject")) content := strings.TrimSpace(r.FormValue("content")) labelsStr := strings.TrimSpace(r.FormValue("labels")) // Validate required fields if subject == "" || content == "" { formData := map[string]string{ "subject": subject, "content": content, "labels": labelsStr, } html, _ := s.htmlGenerator.GenerateContactPage(false, "Subject and message are required", "", formData, nil, []generator.FeedItemInfo{}, nil) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } // Parse labels var labels []string if labelsStr != "" { labelParts := strings.Split(labelsStr, ",") for _, label := range labelParts { label = strings.TrimSpace(label) if label != "" { labels = append(labels, label) } } } // Fetch repo announcement ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() repoAnnouncement, err := s.issueService.FetchRepoAnnouncement(ctx, s.repoAnnouncement) if err != nil { logger.Errorf("Failed to fetch repo announcement: %v", err) formData := map[string]string{ "subject": subject, "content": content, "labels": labelsStr, } html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to connect to repository. Please try again later.", "", formData, nil, []generator.FeedItemInfo{}, nil) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } // Create issue request issueReq := &nostr.IssueRequest{ Subject: subject, Content: content, Labels: labels, } // Publish issue (using anonymous key - server generates random key) eventID, err := s.issueService.PublishIssue(ctx, repoAnnouncement, issueReq, "") if err != nil { logger.Errorf("Failed to publish issue: %v", err) formData := map[string]string{ "subject": subject, "content": content, "labels": labelsStr, } html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to submit your message. Please try again later.", "", formData, nil, []generator.FeedItemInfo{}, nil) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } // Success - render success page html, err := s.htmlGenerator.GenerateContactPage(true, "", eventID, nil, repoAnnouncement, []generator.FeedItemInfo{}, nil) if err != nil { http.Error(w, "Failed to generate success page", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } // handleContactAPI handles API requests for contact form with browser-signed events func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse JSON request var req struct { Event *gonostr.Event `json:"event"` AdditionalRelays []string `json:"additionalRelays,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } if req.Event == nil { http.Error(w, "Event is required", http.StatusBadRequest) return } // Validate event kind (should be kind 1621 for issues per NIP-34) if req.Event.Kind != 1621 { http.Error(w, fmt.Sprintf("Invalid event kind: expected 1621, got %d", req.Event.Kind), http.StatusBadRequest) return } // 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() // Get contact relays contactRelays := s.nostrClient.GetContactRelays() // Combine contact relays with user's outbox relays (if provided) allRelays := make(map[string]bool) for _, relay := range contactRelays { allRelays[relay] = true } // Add user's outbox relays (from their kind 10002 relay list) for _, relay := range req.AdditionalRelays { if relay != "" { allRelays[relay] = true } } // Convert map to slice relaysToPublish := make([]string, 0, len(allRelays)) for relay := range allRelays { relaysToPublish = append(relaysToPublish, relay) } // Publish to all relays (contact relays + user outbox relays) var lastErr error var published bool for _, relayURL := range relaysToPublish { 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 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"}`, req.Event.ID) } // handleStatic serves static files func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { // Serve static files from the static directory 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") } // handleMediaCache serves cached media files func (s *Server) handleMediaCache(w http.ResponseWriter, r *http.Request) { if s.mediaCache == nil { http.Error(w, "Media cache not available", http.StatusServiceUnavailable) return } // Extract filename from path filename := r.URL.Path[len("/cache/media/"):] if filename == "" { http.Error(w, "Invalid path", http.StatusBadRequest) return } // Serve file from cache directory cachePath := filepath.Join(s.mediaCache.GetCacheDir(), filename) http.ServeFile(w, r, cachePath) } // handleHealth handles health check requests func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if s.cache.Size() == 0 { w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("Not ready")) return } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } // handleMetrics handles metrics requests func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") fmt.Fprintf(w, "cache_size %d\n", s.cache.Size()) fmt.Fprintf(w, "feed_items %d\n", len(s.feedCache.Get())) } // handleSitemap handles sitemap requests func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) { // TODO: Generate sitemap from cache w.Header().Set("Content-Type", "application/xml") w.Write([]byte(` `)) } // handleRobots handles robots.txt requests func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("User-agent: *\nAllow: /\nSitemap: /sitemap.xml\n")) } // handle404 handles 404 errors func (s *Server) handle404(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) html, err := s.htmlGenerator.GenerateErrorPage(404, []generator.FeedItemInfo{}) if err != nil { w.Write([]byte("404 - Page Not Found")) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } // servePage serves a cached page with proper headers func (s *Server) servePage(w http.ResponseWriter, r *http.Request, page *cache.CachedPage) { // Set headers w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("ETag", page.ETag) w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Last-Modified", page.LastUpdated.Format(http.TimeFormat)) // Check If-None-Match for conditional requests if match := r.Header.Get("If-None-Match"); match == page.ETag { w.WriteHeader(http.StatusNotModified) return } // Check Accept-Encoding for compression acceptEncoding := r.Header.Get("Accept-Encoding") if strings.Contains(acceptEncoding, "gzip") && len(page.Compressed) > 0 { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Vary", "Accept-Encoding") w.Write(page.Compressed) return } // Serve uncompressed w.Write([]byte(page.Content)) } // middleware adds security headers and logging func (s *Server) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Security headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") // CSP header - allow unpkg.com for Lucide icons and jsdelivr.net for nostr-tools w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;") // Log request (only in debug mode to reduce noise) start := time.Now() next.ServeHTTP(w, r) logger.WithFields(map[string]interface{}{ "method": r.Method, "path": r.URL.Path, "duration": time.Since(start), }).Debug("HTTP request") }) }