with '#' will be ignored, and an empty message aborts the commit. On branch main Initial commit Changes to be committed: new file: .DS_Store new file: .env new file: .gitignore new file: ai-worker/Dockerfile new file: ai-worker/requirements.txt new file: ai-worker/worker.py new file: background-worker/Dockerfile new file: background-worker/go.mod new file: background-worker/go.sum new file: background-worker/main.go new file: background-worker/market.go new file: background-worker/rmv.go new file: background-worker/rss.go new file: background-worker/sql_work.go new file: db/Dockerfile new file: db/init.sql new file: docker-compose.yml new file: server-app/dockerfile new file: server-app/go.mod new file: server-app/go.sum new file: server-app/main.go new file: volumes/.DS_Store new file: volumes/db-init/.DS_Store new file: volumes/db-init/data/news_rss_feeds.csv new file: volumes/web/.DS_Store new file: volumes/web/static/css/blog.css new file: volumes/web/static/css/index-lite.css new file: volumes/web/static/css/index.css new file: volumes/web/static/css/mandelbrot.css new file: volumes/web/static/img/minecraft.png new file: volumes/web/static/js/blog.js new file: volumes/web/static/js/index-lite.js new file: volumes/web/static/js/index.js new file: volumes/web/static/js/mandelbrot.js new file: volumes/web/static/media/cantina.mp3 new file: volumes/web/static/media/countdowns.json new file: volumes/web/static/media/gong.mp4 new file: volumes/web/template/blog.html new file: volumes/web/template/index-lite.html new file: volumes/web/template/index.html new file: volumes/web/template/mandelbrot.html
451 lines
11 KiB
Go
451 lines
11 KiB
Go
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 100
|
|
`)
|
|
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))
|
|
}
|