209 lines
5.8 KiB
Go
209 lines
5.8 KiB
Go
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
|
|
}
|
|
data.History = history
|
|
|
|
// Get next up articles (importance sorted, less than 24 hours old)
|
|
nextUp, err := getNextUpArticles()
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
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")
|
|
}
|