package main import ( "context" "encoding/json" "fmt" "log" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/jackc/pgx/v5" ) type defconState struct { mu sync.RWMutex Level int `json:"level"` } func (d *defconState) get() int { d.mu.RLock() defer d.mu.RUnlock() return d.Level } func (d *defconState) set(level int) { d.mu.Lock() defer d.mu.Unlock() d.Level = level } var defcon = defconState{Level: 5} var marketOrder = []string{ "USD/JPY", "OIL/USD", "XAU/USD", "USD/EUR", "USD/CHF", "USD/GBP", } var marketAliases = map[string]string{ "yen": "USD/JPY", "oil": "OIL/USD", "gold": "XAU/USD", "usd_eur": "USD/EUR", "usd_chf": "USD/CHF", "usd_gbp": "USD/GBP", "EUR/USD": "USD/EUR", "EUR/GBP": "USD/GBP", } var uniMainzNet = mustParseCIDR("134.93.0.0/16") func mustParseCIDR(cidr string) *net.IPNet { _, block, err := net.ParseCIDR(cidr) if err != nil { log.Fatalf("invalid CIDR %q: %v", cidr, err) } return block } func clientIP(r *http.Request) net.IP { var candidates []string if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { parts := strings.Split(fwd, ",") for _, p := range parts { if trimmed := strings.TrimSpace(p); trimmed != "" { candidates = append(candidates, trimmed) } } } if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { candidates = append(candidates, real) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil && host != "" { candidates = append(candidates, host) } else if r.RemoteAddr != "" { candidates = append(candidates, r.RemoteAddr) } for _, raw := range candidates { ip := net.ParseIP(raw) if ip == nil { continue } if ip4 := ip.To4(); ip4 != nil { return ip4 } return ip } return nil } func writeJSON(w http.ResponseWriter, status int, payload interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func buildDSN() string { if url := os.Getenv("DATABASE_URL"); url != "" { return url } host := envOr("POSTGRES_HOST", "database") port := envOr("POSTGRES_PORT", "5432") user := envOr("POSTGRES_USER", "app") pass := envOr("POSTGRES_PASSWORD", "appsecret") name := envOr("POSTGRES_DB", "appdb") return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name) } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func canonicalInstrumentName(name string) (string, bool) { if canon, ok := marketAliases[name]; ok { name = canon } for _, target := range marketOrder { if target == name { return name, true } } return "", false } func fetchLatestQuotes(ctx context.Context, conn *pgx.Conn, requested []string) (map[string]float64, error) { lookup := append([]string{}, requested...) for alias := range marketAliases { lookup = append(lookup, alias) } rows, err := conn.Query(ctx, ` SELECT instrument, bid FROM market_quotes WHERE instrument = ANY($1) ORDER BY quoted_at DESC `, lookup) if err != nil { return nil, err } defer rows.Close() result := map[string]float64{} for rows.Next() { var instrument string var bid float64 if err := rows.Scan(&instrument, &bid); err != nil { return nil, err } canon, ok := canonicalInstrumentName(instrument) if !ok { continue } if _, exists := result[canon]; !exists { result[canon] = bid } } if rows.Err() != nil { return nil, rows.Err() } return result, nil } func withConn(r *http.Request, fn func(conn *pgx.Conn) error) error { ctx := r.Context() conn, err := pgx.Connect(ctx, buildDSN()) if err != nil { return err } defer conn.Close(ctx) return fn(conn) } func serveFile(path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Clean(path)) } } func main() { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) http.HandleFunc("/", serveFile("web/template/index.html")) http.HandleFunc("/lite", serveFile("web/template/index-lite.html")) http.HandleFunc("/blog", serveFile("web/template/blog.html")) http.HandleFunc("/mandelbrot", serveFile("web/template/mandelbrot.html")) http.HandleFunc("/media/gong", serveFile("web/static/media/gong.mp4")) http.HandleFunc("/media/cantina", serveFile("web/static/media/cantina.mp3")) http.HandleFunc("/api/artikeltext", func(w http.ResponseWriter, r *http.Request) { if err := withConn(r, func(conn *pgx.Conn) error { rows, err := conn.Query(r.Context(), ` SELECT title, link, summary, image, published_at FROM articles WHERE image IS NOT NULL ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20 `) if err != nil { return err } defer rows.Close() // 🔑 Header einmal setzen w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") // hilft bei manchen Proxies: w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) // Kickstart: sofort 1 Byte senden, damit Streaming „anfängt“ enc := json.NewEncoder(w) flusher, ok := w.(http.Flusher) if !ok { return fmt.Errorf("streaming not supported") } w.Write([]byte("\n")) flusher.Flush() for rows.Next() { var title, link, summary string var image *string var published *time.Time if err := rows.Scan(&title, &link, &summary, &image, &published); err != nil { return err } item := map[string]interface{}{ "title": title, "link": link, "text": summary, "image": image, "publishedAt": published, } // 👇 EIN Artikel = EIN JSON if err := enc.Encode(item); err != nil { return err } flusher.Flush() // 🚀 sofort senden } return rows.Err() }); err != nil { log.Printf("artikeltext query failed: %v", err) http.Error(w, "stream error", http.StatusInternalServerError) } }) http.HandleFunc("/api/images", func(w http.ResponseWriter, r *http.Request) { if err := withConn(r, func(conn *pgx.Conn) error { rows, err := conn.Query(r.Context(), ` SELECT title, image, published_at FROM articles ORDER BY COALESCE(published_at, created_at) DESC LIMIT 10000 `) if err != nil { return err } defer rows.Close() w.Header().Set("Content-Type", "application/x-ndjson") w.WriteHeader(http.StatusOK) enc := json.NewEncoder(w) flusher, ok := w.(http.Flusher) if !ok { return fmt.Errorf("streaming not supported") } for rows.Next() { var title string var image *string var published *time.Time if err := rows.Scan(&title, &image, &published); err != nil { return err } item := map[string]interface{}{ "title": title, "image": image, "publishedAt": published, } if err := enc.Encode(item); err != nil { return err } flusher.Flush() } if rows.Err() != nil { return rows.Err() } return rows.Err() }); err != nil { // Fallback: keine News statt Fehler, damit die Seite weiterlädt. log.Printf("Image query failed, returning empty list: %v", err) writeJSON(w, http.StatusOK, []map[string]interface{}{}) } }) http.HandleFunc("/api/rmv", func(w http.ResponseWriter, r *http.Request) { if err := withConn(r, func(conn *pgx.Conn) error { rows, err := conn.Query(r.Context(), ` SELECT trip_index, leg_index, linie, abfahrt, ankunft, von, nach FROM rmv_data ORDER BY trip_index, leg_index `) if err != nil { return err } defer rows.Close() type leg struct { Linie string `json:"linie"` Abfahrt string `json:"abfahrt"` Ankunft string `json:"ankunft"` Von string `json:"von"` Nach string `json:"nach"` } byTrip := map[int][]leg{} var maxTrip int for rows.Next() { var tripIdx, legIdx int var l leg if err := rows.Scan(&tripIdx, &legIdx, &l.Linie, &l.Abfahrt, &l.Ankunft, &l.Von, &l.Nach); err != nil { return err } byTrip[tripIdx] = append(byTrip[tripIdx], l) if tripIdx > maxTrip { maxTrip = tripIdx } } if rows.Err() != nil { return rows.Err() } var trips [][]leg for i := 0; i <= maxTrip; i++ { if legs, ok := byTrip[i]; ok { trips = append(trips, legs) } } writeJSON(w, http.StatusOK, map[string]interface{}{"abfahrten": trips}) return nil }); err != nil { log.Printf("rmv query failed: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"}) } }) http.HandleFunc("/api/market", func(w http.ResponseWriter, r *http.Request) { if err := withConn(r, func(conn *pgx.Conn) error { result, err := fetchLatestQuotes(r.Context(), conn, marketOrder) if err != nil { return err } type quote struct { Instrument string `json:"instrument"` Bid float64 `json:"bid"` } var ordered []quote for _, inst := range marketOrder { if bid, ok := result[inst]; ok { ordered = append(ordered, quote{Instrument: inst, Bid: bid}) } } writeJSON(w, http.StatusOK, ordered) return nil }); err != nil { log.Printf("market query failed: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"}) } }) http.HandleFunc("/api/defcon", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]int{"level": defcon.get()}) case http.MethodPost: var body struct { Level int `json:"level"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } if body.Level < 1 || body.Level > 5 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "level must be 1-5"}) return } defcon.set(body.Level) writeJSON(w, http.StatusOK, map[string]int{"level": body.Level}) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) http.HandleFunc("/api/bunker-status", func(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) var ipString string if ip != nil { ipString = ip.String() } inMainz := ip != nil && uniMainzNet.Contains(ip) writeJSON(w, http.StatusOK, map[string]interface{}{ "online": inMainz, "ts": time.Now().UTC(), "client_ip": ipString, "network": uniMainzNet.String(), }) }) http.HandleFunc("/api/countdowns", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Clean("web/static/media/countdowns.json")) }) log.Println("Server läuft auf http://0.0.0.0:8080") log.Fatal(http.ListenAndServe(":8080", nil)) }