package main import ( "database/sql" "encoding/json" "fmt" "html/template" "log" "math" "net/http" "os" "sort" "strconv" "strings" "time" _ "modernc.org/sqlite" // statt github.com/mattn/go-sqlite3 ) /* Quellen */ /* https://starmap.space/api/v3/oc/index.php */ func GetENV(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func Enabled(k string, def bool) bool { b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k))) if err != nil { return def } return b } var ( username = GetENV("KT_USERNAME", "root") password = GetENV("KT_PASSWORD", "root") membername = GetENV("KT_MEMBER", "demo") productive = Enabled("KT_PRODUCTIVE", false) hasimpressum = Enabled("KT_HASIMPRESSUM", false) impressum = GetENV("KT_IMPRESSUM", "") orte = []string{} schiffe = []string{ "", "100i", "125a", "135c", "Arrow", "Aurora CL", "Aurora ES", "Aurora LN", "Aurora LX", "Aurora MR", "Avenger Stalker", "Avenger Titan", "Avenger Titan Renegade", "Avenger Warlock", "Blade", "Buccaneer", "C1 Spirit", "C2 Hercules Starlifter", "C8 Pisces", "C8R Pisces Rescue", "C8X Pisces Expedition", "Carrack", "Caterpillar", "Constellation Andromeda", "Constellation Aquila", "Constellation Phoenix", "Constellation Taurus", "Corsair", "Cutlass Black", "Cutlass Blue", "Cutlass Red", "Cutter", "Defender", "Eclipse", "Freelancer", "Freelancer DUR", "Freelancer MAX", "Gladiator", "Gladius", "Glaive", "Hammerhead", "Hawk", "Herald", "Hurricane", "Idris-K", "Idris-M", "Javelin", "Khartu-Al", "Kraken", "M50", "Merchantman", "Mercury Star Runner", "Mustang Alpha", "Mustang Beta", "Mustang Delta", "Mustang Gamma", "Mustang Omega", "Nomad", "Orion", "P-52 Merlin", "P-72 Archimedes", "Prospector", "Prowler", "Prowler Utility", "Raft", "Reclaimer", "Redeemer", "Reliant Kore", "Reliant Mako", "Reliant Sen", "Reliant Tana", "Retaliator Bomber", "Sabre Peregrine", "Starfarer Gemini", "Talon", "Talon Shrike", "Terrapin", "Vulture", "Hull-A", "Hull-C", "Zeus ES", "Zeus CL", // …weitere Capital- und Concept-Schiffe sind ebenfalls bekannt! } waren = []string{"", "Laranite", "Titanium", "Medical Supplies", "Gold", "Agricium", "Hydrogen", "Nitrogen", "Astatine", "Iodine", "Aluminium", "Copper", "Lithium", "Silicon", "Tungsten", "Aphorite", "Beryl", "Bexalite", "Corundum", "Diamond", "Dolivine", "Hadanite", "Hephaestanite", "Laranite", "Quartz", "Taranite"} ) type POI struct { ItemID int `json:"item_id"` System string `json:"System"` Planet string `json:"Planet"` PoiName string `json:"PoiName"` Type string `json:"Type"` Classification string `json:"Classification"` Latitude float64 `json:"Latitude"` Longitude float64 `json:"Longitude"` Longitude360 float64 `json:"Longitude360"` Height float64 `json:"Height"` XCoord float64 `json:"XCoord"` YCoord float64 `json:"YCoord"` ZCoord float64 `json:"ZCoord"` QTMarker int `json:"QTMarker"` NextPOI string `json:"NextPOI"` NextQTMarker string `json:"NextQTMarker"` Comment string `json:"Comment"` Submitted string `json:"Submitted"` // oder time.Time, falls du umwandelst Introduced string `json:"Introduced"` // z. B. "Unknown" GUID string `json:"GUID"` POISize *string `json:"POI_Size"` POIType *string `json:"POI_Type"` POIEntries string `json:"POI_Entries"` POIAccessableFoot string `json:"POI_Accessable_Foot"` POIAccessableVehicle string `json:"POI_Accessable_Vehicle"` POIAccessableShip string `json:"POI_Accessable_Ship"` POIDefenses string `json:"POI_Defenses"` POILandingPads string `json:"POI_LandingPads"` POIVehiclePads string `json:"POI_VehiclePads"` POITerminals string `json:"POI_Terminals"` POIMedBay string `json:"POI_MedBay"` POIServices string `json:"POI_Services"` POIAtmosphere *string `json:"POI_Atmosphere"` POINPCs string `json:"POI_NPCs"` ZoneArmistice int `json:"Zone_Armistice"` ZoneNoFly int `json:"Zone_NoFly"` ZoneTrespassing int `json:"Zone_Trespassing"` ZoneBiome string `json:"Zone_Biome"` ZoneGravitation int `json:"Zone_Gravitation"` ZoneTemperatureMin string `json:"Zone_Temperature_Min"` ZoneTemperatureMax string `json:"Zone_Temperature_Max"` Minerals *string `json:"Minerals"` SpecialLoot string `json:"SpecialLoot"` Missions string `json:"Missions"` } type Entry struct { ID int Anfangsbestand float64 Endbestand float64 Prozentwert float64 Abgabe float64 Gesamtwert float64 Bezahlt bool CreatedAt string Startort string Zielort string Schiff string Ware string Zeitaufwand float64 } type Abteilung struct { Name string Anteil float64 // in Prozent Wert float64 // berechnet: Anteil * Summe / 100 WertOffen float64 // berechnet: Anteil * Summe / 100 (von offen) Beispiel string WertItem float64 } type Monatsstatistik struct { Monat string // z. B. "07.2025" Summe float64 // bezahlte SummeOffen float64 // noch nicht bezahlt } var tmpl = template.Must(template.New("form").Funcs(template.FuncMap{ "formatNumber": formatNumber, "div": func(a, b float64) float64 { if b == 0 { return 0 } return math.Floor(a / b) }, "formatDate": func(dateStr string) string { t, err := time.Parse("2006-01-02 15:04:05", dateStr) if err != nil { return "?" } return t.Format("02.01.2006") }, }).Parse(htmlTemplate)) // Tausendertrenner für deutsche Zahlendarstellung (z. B. 12345 → "12.345") func formatNumber(n float64) string { intVal := int64(n + 0.5) // runden s := strconv.FormatInt(intVal, 10) nStr := "" for i, r := range reverse(s) { if i > 0 && i%3 == 0 { nStr = "." + nStr } nStr = string(r) + nStr } return nStr } func reverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) } func isAuthenticated(r *http.Request) bool { cookie, err := r.Cookie("session") return err == nil && cookie.Value == "authenticated" } func main() { var ( db *sql.DB err error data []byte ) if productive { db, err = sql.Open("sqlite", "/data/data.db") if err != nil { panic(err) } data, err = os.ReadFile("/dynamicsrc/pois.json") if err != nil { panic(err) } } else { db, err = sql.Open("sqlite", "./data.db") if err != nil { panic(err) } data, err = os.ReadFile("./dynamicsrc/pois.json") if err != nil { panic(err) } } var pois []POI if err := json.Unmarshal(data, &pois); err != nil { panic(err) } orte = append(orte, "") for _, poi := range pois { formatted := fmt.Sprintf("%s - %s - %s (%s)", poi.System, poi.Planet, poi.PoiName, poi.Type) orte = append(orte, formatted) } // if err != nil { log.Fatal(err) } defer db.Close() createTable(db) if productive { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("/data/static")))) } else { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) } http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { r.ParseForm() user := r.FormValue("username") pass := r.FormValue("password") if user == username && pass == password { http.SetCookie(w, &http.Cookie{ Name: "session", Value: "authenticated", Path: "/", }) http.Redirect(w, r, "/", http.StatusSeeOther) return } http.Error(w, "Login fehlgeschlagen", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "text/html") w.Write([]byte(loginForm)) }) http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, }) http.Redirect(w, r, "/", http.StatusSeeOther) }) http.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { http.Error(w, "Nicht autorisiert", http.StatusUnauthorized) return } id := r.URL.Query().Get("id") if id != "" { db.Exec("DELETE FROM eintraege WHERE id = ?", id) } http.Redirect(w, r, "/", http.StatusSeeOther) }) http.HandleFunc("/reset", func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { http.Error(w, "Nicht autorisiert", http.StatusUnauthorized) return } // Tabelle leeren db.Exec("DELETE FROM eintraege") // Auto-Increment-Zähler zurücksetzen db.Exec("DELETE FROM sqlite_sequence WHERE name='eintraege'") http.Redirect(w, r, "/", http.StatusSeeOther) }) http.HandleFunc("/markaspaid", func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { http.Error(w, "Nicht autorisiert", http.StatusUnauthorized) return } id := r.URL.Query().Get("id") if id != "" { db.Exec("UPDATE eintraege SET bezahlt = 1 WHERE id = ?", id) } http.Redirect(w, r, "/", http.StatusSeeOther) }) http.HandleFunc("/unmarkaspaid", func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { http.Error(w, "Nicht autorisiert", http.StatusUnauthorized) return } id := r.URL.Query().Get("id") if id != "" { db.Exec("UPDATE eintraege SET bezahlt = 0 WHERE id = ?", id) } http.Redirect(w, r, "/", http.StatusSeeOther) }) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { if !isAuthenticated(r) { http.Error(w, "Nicht autorisiert", http.StatusUnauthorized) return } r.ParseForm() anfang, _ := strconv.ParseFloat(r.FormValue("anfangsbestand"), 64) ende, _ := strconv.ParseFloat(r.FormValue("endbestand"), 64) prozent, _ := strconv.ParseFloat(r.FormValue("prozentwert"), 64) diff := ende - anfang abgabe := (diff / 100) * prozent startort := r.FormValue("startort") zielort := r.FormValue("zielort") schiff := r.FormValue("schiff") ware := r.FormValue("ware") zeitaufwand, _ := strconv.ParseFloat(r.FormValue("zeitaufwand"), 64) _, err := db.Exec(`INSERT INTO eintraege (anfangsbestand, endbestand, prozentwert, abgabe, created_at, startort, zielort, schiff, ware, zeitaufwand) VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)`, anfang, ende, prozent, abgabe, startort, zielort, schiff, ware, zeitaufwand) if err != nil { http.Error(w, "Fehler beim Einfügen", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) return } rows, err := db.Query(`SELECT id, anfangsbestand, endbestand, prozentwert, abgabe, bezahlt, created_at, startort, zielort, schiff, ware, zeitaufwand FROM eintraege`) if err != nil { http.Error(w, "Fehler beim Abrufen", http.StatusInternalServerError) return } defer rows.Close() var eintraege []Entry var summe float64 var offeneSumme float64 for rows.Next() { var e Entry var bezahlt int var createdAt sql.NullString var startort sql.NullString var zielort sql.NullString var schiff sql.NullString var ware sql.NullString var zeitaufwand sql.NullFloat64 err := rows.Scan(&e.ID, &e.Anfangsbestand, &e.Endbestand, &e.Prozentwert, &e.Abgabe, &bezahlt, &createdAt, &startort, &zielort, &schiff, &ware, &zeitaufwand) if err != nil { log.Println("Fehler beim Scan:", err) continue } e.Gesamtwert = e.Endbestand - e.Anfangsbestand e.Bezahlt = bezahlt == 1 if !e.Bezahlt { offeneSumme += e.Abgabe } else { summe += e.Abgabe } if createdAt.Valid { e.CreatedAt = createdAt.String } else { e.CreatedAt = "unbekannt" } /**/ if startort.Valid { e.Startort = startort.String } else { e.Startort = "unbekannt" } if zielort.Valid { e.Zielort = zielort.String } else { e.Zielort = "unbekannt" } if schiff.Valid { e.Schiff = schiff.String } else { e.Schiff = "unbekannt" } if ware.Valid { e.Ware = ware.String } else { e.Ware = "unbekannt" } if zeitaufwand.Valid { e.Zeitaufwand = zeitaufwand.Float64 } else { e.Zeitaufwand = 0 } eintraege = append(eintraege, e) } monatsMap := map[string]*Monatsstatistik{} for _, e := range eintraege { parsed, err := time.Parse("2006-01-02 15:04:05", e.CreatedAt) if err != nil { continue } monatKey := parsed.Format("01.2006") // z. B. "07.2025" if _, ok := monatsMap[monatKey]; !ok { monatsMap[monatKey] = &Monatsstatistik{Monat: monatKey} } if e.Bezahlt { monatsMap[monatKey].Summe += e.Abgabe } else { monatsMap[monatKey].SummeOffen += e.Abgabe } } var monatsStat []Monatsstatistik for _, stat := range monatsMap { monatsStat = append(monatsStat, *stat) } sort.Slice(monatsStat, func(i, j int) bool { // Nach Datum aufsteigend sortieren ti, _ := time.Parse("01.2006", monatsStat[i].Monat) tj, _ := time.Parse("01.2006", monatsStat[j].Monat) return ti.Before(tj) }) //test // Dynamische Abteilungen – frei anpassbar abteilungen := []Abteilung{ {Name: "Raumkampf", Anteil: 15, Beispiel: "CF-337 Panther", WertItem: 36308}, {Name: "Bodenkampf", Anteil: 8, Beispiel: "P4-AR Rifle", WertItem: 5900}, {Name: "Racing", Anteil: 3, Beispiel: "LumaCore - Power Plant", WertItem: 69300}, {Name: "Medical", Anteil: 5, Beispiel: "ParaMed Medical Device", WertItem: 1250}, {Name: "Exploration", Anteil: 3, Beispiel: "Pembroke Exploration Suit", WertItem: 15000}, {Name: "Rettung", Anteil: 5, Beispiel: "GSX-HP Fuel-Pod", WertItem: 115200}, {Name: "Logistik", Anteil: 8, Beispiel: "MaxLift Tractor Beam", WertItem: 19175}, {Name: "Mining", Anteil: 3, Beispiel: "Helix II", WertItem: 108000}, {Name: "Salvaging", Anteil: 3, Beispiel: "Abrade Scraper Module", WertItem: 21250}, {Name: "Trading", Anteil: 3, Beispiel: "MaxLift Tractor Beam", WertItem: 19175}, {Name: "Basebuilding (+10)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308}, {Name: "Crafting (+8)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308}, {Name: "Forschung (+5)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308}, {Name: "Events (-23)", Anteil: 38, Beispiel: "CF-337 Panther", WertItem: 36308}, {Name: "Roleplay", Anteil: 3, Beispiel: "Clothing", WertItem: 8400}, {Name: "Kunstflug", Anteil: 3, Beispiel: "Beacon Undersuit Crimson", WertItem: 1000}, } for i := range abteilungen { abteilungen[i].Wert = (abteilungen[i].Anteil / 100) * summe abteilungen[i].WertOffen = (abteilungen[i].Anteil / 100) * offeneSumme } tmpl.Execute(w, struct { Entries []Entry Summe float64 OffeneSumme float64 Abteilungen []Abteilung Monatsstatistik []Monatsstatistik LoggedIn bool Member string HasImpressum bool Impressum string Orte []string Schiffe []string Waren []string }{ Entries: eintraege, Summe: summe, OffeneSumme: offeneSumme, Abteilungen: abteilungen, Monatsstatistik: monatsStat, LoggedIn: isAuthenticated(r), Member: membername, HasImpressum: hasimpressum, Impressum: impressum, Orte: orte, Schiffe: schiffe, Waren: waren, }) }) log.Println("Server läuft auf http://0.0.0.0:8080") http.ListenAndServe(":8080", nil) } func createTable(db *sql.DB) { _, err := db.Exec(` CREATE TABLE IF NOT EXISTS eintraege ( id INTEGER PRIMARY KEY AUTOINCREMENT, anfangsbestand REAL, endbestand REAL, prozentwert REAL, abgabe REAL, bezahlt INTEGER DEFAULT 0, created_at TEXT, startort TEXT, zielort TEXT, schiff TEXT, ware TEXT, zeitaufwand INTEGER ); `) if err != nil { log.Fatal(err) } // Ergänze ALTER TABLE nur für Migration bestehender Tabellen addColumn := func(column, colType string) { _, err := db.Exec(`ALTER TABLE eintraege ADD COLUMN ` + column + ` ` + colType) if err != nil && !strings.Contains(err.Error(), "duplicate column") { log.Fatalf("Fehler beim Hinzufügen der Spalte %s: %v", column, err) } } addColumn("bezahlt", "INTEGER DEFAULT 0") addColumn("created_at", "TEXT") addColumn("startort", "TEXT") addColumn("zielort", "TEXT") addColumn("schiff", "TEXT") addColumn("ware", "TEXT") addColumn("zeitaufwand", "INTEGER") } const loginForm = ` Login

Login

` const htmlTemplate = ` Abgabe-Berechnung
{{if .LoggedIn}}
{{else}} Login {{end}}

Beitrag zur Community vom Mitglied der Trading-Staffel ({{.Member}})

Folgender Wert wurde erwirtschaftet und wird bald zur Verfügung gestellt: {{formatNumber .OffeneSumme}} UEC
Summe aller getätigten Abgaben an die Community: {{formatNumber .Summe}} UEC

{{if .LoggedIn}}
{{end}}

Gespeicherte Einträge

{{if .LoggedIn}}{{else}}{{end}} {{range .Entries}} {{end}}
# Datum UEC Anfang UEC Ende UEC Profit Prozent UEC Abgabe StatusAktionErweitert
{{.ID}} {{formatDate .CreatedAt}} {{formatNumber .Anfangsbestand}} {{formatNumber .Endbestand}} {{formatNumber .Gesamtwert}} {{formatNumber .Prozentwert}}% {{formatNumber .Abgabe}} {{if .Bezahlt}} {{if $.LoggedIn}} Als unverteilt markieren {{else}} ✓ verteilt {{end}} {{else}} {{if $.LoggedIn}} Als verteilt markieren {{else}} ✗ nicht verteilt {{end}} {{end}} {{if $.LoggedIn}} Löschen {{end}}
Interne Infos (Details):
Startort Zielort Schiff Ware Zeit (min)
{{.Startort}} {{.Zielort}} {{.Schiff}} {{.Ware}} {{formatNumber .Zeitaufwand}}

Auswertungen

Monatliche Übersicht
{{range .Monatsstatistik}} {{end}}
Monat Abgaben verteilt Abgaben offen
{{.Monat}} {{formatNumber .Summe}} UEC {{formatNumber .SummeOffen}} UEC
Die tatsächlichen Werte können abweichen. Die dargestellten Werte sind meine Vorstellung einer sinnvollen Verteilung.
Die Summe wird an die Orga-Leitung entrichtet. Die endgültige Entscheidung über die Verteilung obliegt der Orga-Leitung.
Verteilung auf Abteilungen:
{{range .Abteilungen}} {{end}}
Abteilung Verteilungsschlüssel Summe verteilt Summe offen
{{.Name}} {{formatNumber .Anteil}}% {{formatNumber .Wert}} UEC {{formatNumber .WertOffen}} UEC
Gegenwert in Items:
{{range .Abteilungen}} {{end}}
Abteilung Beispiel Wert pro Item Summe verteilt Menge
{{.Name}} {{.Beispiel}} {{formatNumber .WertItem}} UEC {{formatNumber .Wert}} UEC {{if gt .WertItem 0.0}} {{formatNumber (div .Wert .WertItem)}} {{else}} - {{end}}
{{if .LoggedIn}}
{{end}}
{{if .HasImpressum}}
Impressum
{{end}}
`