package main import ( "context" "fmt" "html/template" "net/http" "strings" "time" ) // webServer runs the web interface on port 8080 func webServer(shutdown chan struct{}) { // Load templates tmpl, err := template.ParseFiles("templates/index.html") if err != nil { logInfo("web", "Error loading templates", map[string]interface{}{ "error": err.Error(), }) return } // Create a custom request handler with security headers handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add security headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") // Enforce request method if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit request body size (1MB) r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // Only serve the index page if r.URL.Path != "/" { http.NotFound(w, r) return } data, err := getDashboardData() if err != nil { http.Error(w, "Error fetching data: "+err.Error(), http.StatusInternalServerError) return } err = tmpl.Execute(w, data) if err != nil { // Check if it's already too late to write headers if !isResponseHeaderWritten(err) { http.Error(w, "Error rendering template: "+err.Error(), http.StatusInternalServerError) } else { // Log the error but don't try to write headers again logInfo("web", "Template execution error after headers sent", map[string]interface{}{ "error": err.Error(), }) } return } }) // Configure server with appropriate timeouts server := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 10 * time.Second, // Time to read the request WriteTimeout: 30 * time.Second, // Time to write the response IdleTimeout: 60 * time.Second, // Keep-alive connections timeout MaxHeaderBytes: 1 << 20, // 1MB max header size } // Create a goroutine for the server go func() { logInfo("web", "Starting web server", map[string]interface{}{ "port": 8080, }) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logInfo("web", "Web server error", map[string]interface{}{ "error": err.Error(), }) } }() // Wait for shutdown signal <-shutdown // Create a deadline for server shutdown ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Attempt graceful shutdown if err := server.Shutdown(ctx); err != nil { logInfo("web", "Error during server shutdown", map[string]interface{}{ "error": err.Error(), }) } logInfo("web", "Web server stopped", nil) } // getDashboardData fetches the data needed for the dashboard func getDashboardData() (DashboardData, error) { articles := loadArticles() now := time.Now() hourAgo := now.Add(-60 * time.Minute) // Prepare the data structure data := DashboardData{ LastUpdated: now.Format("Jan 02, 2006 15:04:05 MST"), TotalArticles: len(articles), } // Count broadcast articles, recent articles, and unsummarized articles var lastBroadcastTime time.Time for _, a := range articles { if !a.BroadcastTime.IsZero() && a.BroadcastTime.Unix() > 1 { data.TotalBroadcast++ // Track the most recent broadcast time if a.BroadcastTime.After(lastBroadcastTime) { lastBroadcastTime = a.BroadcastTime } } if a.FirstSeen.After(hourAgo) { data.NewInLastHour++ } if a.Summary == "" || a.Importance == 0 { data.UnsummarizedCount++ } } // Set the last broadcast time data.LastBroadcastTime = lastBroadcastTime // Calculate time until next broadcast if lastBroadcastTime.IsZero() { data.NextBroadcastIn = "As soon as articles are summarized" } else { nextBroadcastTime := lastBroadcastTime.Add(BROADCAST_INTERVAL) if now.After(nextBroadcastTime) { // If we're past the interval but haven't broadcast yet, // likely waiting for articles to be summarized if data.UnsummarizedCount > 0 { data.NextBroadcastIn = "Waiting for articles to be summarized" } else { data.NextBroadcastIn = "Pending (checking every " + BROADCAST_CHECK_INTERVAL.String() + ")" } } else { // We're still within the interval, calculate remaining time timeUntilNextBroadcast := nextBroadcastTime.Sub(now) // Format as hours and minutes hours := int(timeUntilNextBroadcast.Hours()) minutes := int(timeUntilNextBroadcast.Minutes()) % 60 if hours > 0 { data.NextBroadcastIn = fmt.Sprintf("%dh %dm", hours, minutes) } else { data.NextBroadcastIn = fmt.Sprintf("%dm", minutes) } } } // Get broadcast history (last 100) history, err := getBroadcastHistory(100) if err != nil { return data, err } // Add relative time information to history articles history = addRelativeTimes(history) data.History = history // Get next up articles (importance sorted, less than 24 hours old) nextUp, err := getNextUpArticles() if err != nil { return data, err } // Add relative time information to next up articles nextUp = addRelativeTimes(nextUp) data.NextUp = nextUp // Get recent logs recentLogs, err := getRecentLogs(100) if err != nil { logInfo("web", "Error fetching recent logs", map[string]interface{}{ "error": err.Error(), }) // Continue with empty logs list recentLogs = []LogEntry{} } data.RecentLogs = recentLogs return data, nil } // isResponseHeaderWritten checks if an error indicates headers were already written func isResponseHeaderWritten(err error) bool { // Check common patterns that indicate the response was already partially written errStr := err.Error() return strings.Contains(errStr, "write: broken pipe") || strings.Contains(errStr, "write: connection reset by peer") || strings.Contains(errStr, "http: superfluous response.WriteHeader") } // formatRelativeTime returns a human-readable relative time string func formatRelativeTime(t time.Time) string { if t.IsZero() { return "" } now := time.Now() diff := now.Sub(t) // Less than a minute if diff < time.Minute { return "just now" } // Less than an hour if diff < time.Hour { minutes := int(diff.Minutes()) return fmt.Sprintf("%dm ago", minutes) } // Less than a day if diff < 24*time.Hour { hours := int(diff.Hours()) return fmt.Sprintf("%dh ago", hours) } // Less than a week if diff < 7*24*time.Hour { days := int(diff.Hours() / 24) return fmt.Sprintf("%dd ago", days) } // More than a week weeks := int(diff.Hours() / 24 / 7) return fmt.Sprintf("%dw ago", weeks) } // Add relative time information to articles func addRelativeTimes(articles []Article) []Article { for i := range articles { articles[i].RelativeTime = formatRelativeTime(articles[i].FirstSeen) articles[i].BroadcastRelativeTime = formatRelativeTime(articles[i].BroadcastTime) } return articles }