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
}