// main.go // Go 1.21+ // Einfache Webanwendung mit Login, Persistenz (JSON-Datei), Audit-Log, // und UI ohne externe Verweise. Nutzer können "Benutzernamen" melden, // Einträge starten mit Status "Gemeldet" und können von einem anderen // Nutzer bestätigt werden. Alles wird audit-geeignet protokolliert. // // Hinweise: Für Produktion TLS, CSRF, bcrypt/argon2id etc. ergänzen. package main import ( "context" "crypto/hmac" crand "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "html/template" "log" mrand "math/rand" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" ) // ======= Datenmodelle ======= type User struct { Username string `json:"username"` PasswordHash string `json:"password_hash"` IsAdmin bool `json:"is_admin"` CreatedAt int64 `json:"created_at"` } type Status string const ( StatusGemeldet Status = "Gemeldet" StatusBestaetigt Status = "Bestätigt" ) type Report struct { ID int64 `json:"id"` Name string `json:"name"` Status Status `json:"status"` ReportedBy string `json:"reported_by"` ConfirmedBy string `json:"confirmed_by"` CreatedAt int64 `json:"created_at"` ConfirmedAt int64 `json:"confirmed_at"` } type AuditEntry struct { Time int64 `json:"time"` Actor string `json:"actor"` Action string `json:"action"` Details string `json:"details"` IP string `json:"ip"` } type DB struct { Users []User `json:"users"` Reports []Report `json:"reports"` Audit []AuditEntry `json:"audit"` NextID int64 `json:"next_id"` } // ======= Globale Variablen ======= var ( dataPath = envOr("DATA_FILE", "data.json") cookieName = envOr("COOKIE_NAME", "session") secretKey []byte // für HMAC der Session-Cookies mu sync.Mutex db DB tpl *template.Template ) // ======= Hilfsfunktionen ======= func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func loadDB() error { mu.Lock() defer mu.Unlock() f, err := os.Open(dataPath) if err != nil { if os.IsNotExist(err) { db = DB{NextID: 1} return saveDB() } return err } defer f.Close() decoder := json.NewDecoder(f) return decoder.Decode(&db) } func saveDB() error { f, err := os.CreateTemp(filepath.Dir(dataPath), "dbtmp-*.json") if err != nil { return err } enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(db); err != nil { f.Close() os.Remove(f.Name()) return err } f.Close() return os.Rename(f.Name(), dataPath) } // ======= Pseudo-PBKDF (nur Demo!) ======= // Für Produktion: bcrypt/argon2id verwenden. func hashPassword(pw string, salt []byte) string { h := sha256.New() h.Write(salt) h.Write([]byte(pw)) for i := 0; i < 100000; i++ { // 100k Runden h.Write([]byte{byte(i)}) } return base64.StdEncoding.EncodeToString(h.Sum(nil)) + ":" + base64.StdEncoding.EncodeToString(salt) } func verifyPassword(pw, stored string) bool { parts := strings.Split(stored, ":") if len(parts) != 2 { return false } salt, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return false } return hashPassword(pw, salt) == stored } func newSalt() []byte { b := make([]byte, 16); crand.Read(b); return b } // ======= Sessions (signiertes Cookie) ======= type Session struct { Username string Expires int64 } func sign(data []byte) string { mac := hmac.New(sha256.New, secretKey) mac.Write(data) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func setSession(w http.ResponseWriter, username string) { s := fmt.Sprintf("%s|%d", username, time.Now().Add(24*time.Hour).Unix()) sig := sign([]byte(s)) value := base64.StdEncoding.EncodeToString([]byte(s)) + "." + sig http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: value, Path: "/", HttpOnly: true, Secure: false, // In Produktion: true + HTTPS SameSite: http.SameSiteLaxMode, }) } func clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", Expires: time.Unix(0, 0)}) } func getSession(r *http.Request) (string, bool) { c, err := r.Cookie(cookieName) if err != nil { return "", false } parts := strings.SplitN(c.Value, ".", 2) if len(parts) != 2 { return "", false } payloadB, err := base64.StdEncoding.DecodeString(parts[0]) if err != nil { return "", false } if sign(payloadB) != parts[1] { return "", false } payload := string(payloadB) sp := strings.Split(payload, "|") if len(sp) != 2 { return "", false } exp, _ := strconv.ParseInt(sp[1], 10, 64) if time.Now().Unix() > exp { return "", false } return sp[0], true } // ======= Middleware ======= type ctxKey string var userKey ctxKey = "user" func withAuth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := getSession(r) if !ok { http.Redirect(w, r, "/login", http.StatusFound) return } ctx := context.WithValue(r.Context(), userKey, u) r = r.WithContext(ctx) h(w, r) } } func currentUser(r *http.Request) string { if v := r.Context().Value(userKey); v != nil { if s, ok := v.(string); ok { return s } } return "" } // ======= Template & Assets ======= var baseTpl = `{{define "base"}} {{block "title" .}}Meldesystem{{end}}
Meldesystem
👤 {{.User}}
{{template "content" .}}
{{end}}` var loginTpl = `{{define "login"}} Login · Meldesystem

Anmeldung

{{if .Msg}}
{{.Msg}}
{{end}} {{if .Setup}}
Erstbenutzer: {{.SetupUser}} / Passwort: {{.SetupPass}} (bitte nach dem Login ändern)
{{end}}
{{end}}` var dashboardTpl = `{{define "dashboard"}}{{template "base" .}}{{end}} {{define "content"}}

Neuen Benutzernamen melden

Einträge starten mit Status Gemeldet und müssen von einem anderen Nutzer bestätigt werden.

Audit-Log

Letzte 8 Ereignisse
{{range .RecentAudit}} {{else}} {{end}}
ZeitAktionDetails
{{.When}} {{.Action}} {{.Details}}
Noch keine Ereignisse

Gemeldete Benutzernamen

Gesamtes Audit
{{range .Reports}} {{else}} {{end}}
IDNameStatusAktionen
#{{.ID}} {{.Name}} {{if eq .Status "Gemeldet"}} Gemeldet {{else}} Bestätigt {{end}}
von {{.ReportedBy}} am {{.Created}}
{{if .Confirmed}}
bestätigt von {{.ConfirmedBy}} am {{.Confirmed}}
{{end}}
{{if eq .Status "Gemeldet"}}
{{else}} ✔ Bereits bestätigt {{end}} {{if $.IsAdmin}}
{{end}}
Keine Einträge
{{end}}` var auditTpl = `{{define "audit_page"}} Audit · Meldesystem
Meldesystem
👤 {{.User}}

Audit-Log (vollständig)

{{range .Rows}} {{else}} {{end}}
ZeitNutzerAktionDetailsIP
{{.When}} {{.Actor}} {{.Action}} {{.Details}} {{.IP}}
Noch keine Ereignisse
{{end}}` // ======= Server ======= func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) // Geheimschlüssel laden/erzeugen if k := os.Getenv("APP_SECRET"); k != "" { secretKey = []byte(k) } else { secretKey = make([]byte, 32) if _, err := crand.Read(secretKey); err != nil { log.Fatal(err) } } if err := loadDB(); err != nil { log.Fatal(err) } setupUser, setupPass, setup := ensureAdmin() tpl = template.Must(template.New("base").Parse(baseTpl)) template.Must(tpl.New("dashboard_bundle").Parse(dashboardTpl)) template.Must(tpl.New("audit_bundle").Parse(auditTpl)) http.HandleFunc("/", withAuth(handleIndex)) http.HandleFunc("/report", withAuth(handleReport)) http.HandleFunc("/confirm", withAuth(handleConfirm)) http.HandleFunc("/delete", withAuth(handleDelete)) http.HandleFunc("/audit", withAuth(handleAudit)) http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { t := template.Must(template.New("login").Parse(loginTpl)) msg := r.URL.Query().Get("msg") _ = t.ExecuteTemplate(w, "login", map[string]any{"Msg": msg, "Setup": setup, "SetupUser": setupUser, "SetupPass": setupPass}) return } if r.Method == http.MethodPost { username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") if authenticate(username, password) { setSession(w, username) appendAudit(username, "login", "Erfolg", r) http.Redirect(w, r, "/", http.StatusFound) return } appendAudit(username, "login", "Fehlgeschlagen", r) http.Redirect(w, r, "/login?msg=Ungültige%20Anmeldedaten", http.StatusFound) return } w.WriteHeader(http.StatusMethodNotAllowed) }) http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } u := currentUser(r) clearSession(w) appendAudit(u, "logout", "", r) http.Redirect(w, r, "/login?msg=Abgemeldet", http.StatusFound) }) addr := envOr("ADDR", ":8080") log.Printf("Server läuft auf %s", addr) log.Fatal(http.ListenAndServe(addr, nil)) } // ======= Admin/Users ======= func ensureAdmin() (string, string, bool) { mu.Lock() defer mu.Unlock() if len(db.Users) > 0 { return "", "", false } user := envOr("ADMIN_USER", "admin") pass := os.Getenv("ADMIN_PASS") if pass == "" { pass = randomPassword(12) } h := hashPassword(pass, newSalt()) db.Users = append(db.Users, User{Username: user, PasswordHash: h, IsAdmin: true, CreatedAt: time.Now().Unix()}) if err := saveDB(); err != nil { log.Fatal(err) } log.Printf("Erstbenutzer angelegt: %s / %s (bitte ändern)", user, pass) return user, pass, true } func authenticate(username, password string) bool { mu.Lock() defer mu.Unlock() for _, u := range db.Users { if u.Username == username && verifyPassword(password, u.PasswordHash) { return true } } return false } func isAdmin(username string) bool { mu.Lock() defer mu.Unlock() for _, u := range db.Users { if u.Username == username { return u.IsAdmin } } return false } // ======= Audit ======= func appendAudit(actor, action, details string, r *http.Request) { mu.Lock() defer mu.Unlock() entry := AuditEntry{Time: time.Now().Unix(), Actor: actor, Action: action, Details: details, IP: clientIP(r)} db.Audit = append([]AuditEntry{entry}, db.Audit...) // neueste oben if len(db.Audit) > 5000 { db.Audit = db.Audit[:5000] } _ = saveDB() } func clientIP(r *http.Request) string { if x := r.Header.Get("X-Forwarded-For"); x != "" { return strings.Split(x, ",")[0] } return strings.Split(r.RemoteAddr, ":")[0] } // ======= Handlers ======= func handleIndex(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) mu.Lock() reports := make([]Report, 0, len(db.Reports)) for _, rep := range db.Reports { if q == "" || strings.Contains(strings.ToLower(rep.Name), strings.ToLower(q)) { reports = append(reports, rep) } } mu.Unlock() rows := make([]map[string]any, 0, len(reports)) for _, rep := range reports { m := map[string]any{ "ID": rep.ID, "Name": template.HTMLEscapeString(rep.Name), "Status": rep.Status, "ReportedBy": rep.ReportedBy, "Created": time.Unix(rep.CreatedAt, 0).Format("02.01.2006 15:04"), } if rep.Status == StatusBestaetigt { m["Confirmed"] = time.Unix(rep.ConfirmedAt, 0).Format("02.01.2006 15:04") m["ConfirmedBy"] = rep.ConfirmedBy } rows = append(rows, m) } recent := recentAudit(8) data := map[string]any{ "User": currentUser(r), "IsAdmin": isAdmin(currentUser(r)), "Reports": rows, "Q": q, "RecentAudit": recent, "Now": time.Now().Format("02.01.2006 15:04"), } _ = tpl.ExecuteTemplate(w, "dashboard", data) } func handleReport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Redirect(w, r, "/?msg=leer", http.StatusFound) return } user := currentUser(r) mu.Lock() for _, rep := range db.Reports { if strings.EqualFold(rep.Name, name) { mu.Unlock() appendAudit(user, "report_skip", fmt.Sprintf("Duplikat: %s", name), r) http.Redirect(w, r, "/?msg=exists", http.StatusFound) return } } id := db.NextID db.NextID++ rep := Report{ID: id, Name: name, Status: StatusGemeldet, ReportedBy: user, CreatedAt: time.Now().Unix()} db.Reports = append([]Report{rep}, db.Reports...) _ = saveDB() mu.Unlock() appendAudit(user, "report", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r) http.Redirect(w, r, "/", http.StatusFound) } func handleConfirm(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) user := currentUser(r) mu.Lock() defer mu.Unlock() for i, rep := range db.Reports { if rep.ID == id { if rep.ReportedBy == user { // nicht selbst bestätigen appendAudit(user, "confirm_denied", fmt.Sprintf("ID %d · eigener Eintrag", id), r) break } if rep.Status == StatusBestaetigt { break } rep.Status = StatusBestaetigt rep.ConfirmedBy = user rep.ConfirmedAt = time.Now().Unix() db.Reports[i] = rep _ = saveDB() appendAudit(user, "confirm", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r) break } } http.Redirect(w, r, "/", http.StatusFound) } func handleDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if !isAdmin(currentUser(r)) { w.WriteHeader(http.StatusForbidden) return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) user := currentUser(r) mu.Lock() defer mu.Unlock() for i, rep := range db.Reports { if rep.ID == id { db.Reports = append(db.Reports[:i], db.Reports[i+1:]...) _ = saveDB() appendAudit(user, "delete", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r) break } } http.Redirect(w, r, "/", http.StatusFound) } func handleAudit(w http.ResponseWriter, r *http.Request) { rows := recentAudit(1000) data := map[string]any{"User": currentUser(r), "Rows": rows, "Now": time.Now().Format("02.01.2006 15:04"), "IsAdmin": isAdmin(currentUser(r))} _ = tpl.ExecuteTemplate(w, "audit_page", data) } func recentAudit(n int) []map[string]any { mu.Lock() defer mu.Unlock() limit := n if len(db.Audit) < n { limit = len(db.Audit) } out := make([]map[string]any, 0, limit) for _, a := range db.Audit[:limit] { out = append(out, map[string]any{ "When": time.Unix(a.Time, 0).Format("02.01.2006 15:04:05"), "Actor": a.Actor, "Action": a.Action, "Details": a.Details, "IP": a.IP, }) } return out } // ======= Utilities ======= func randomPassword(n int) string { letters := []rune("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#%") b := make([]rune, n) s := mrand.New(mrand.NewSource(time.Now().UnixNano())) for i := range b { b[i] = letters[s.Intn(len(letters))] } return string(b) }