Please enter the commit message for your changes. Lines starting

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
This commit is contained in:
hubble_dubble
2026-01-26 00:19:54 +01:00
commit 3667c678e4
41 changed files with 3556 additions and 0 deletions

450
server-app/main.go Normal file
View File

@@ -0,0 +1,450 @@
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))
}