diff --git a/data.json b/data.json new file mode 100644 index 0000000..fbc288e --- /dev/null +++ b/data.json @@ -0,0 +1,87 @@ +{ + "users": [ + { + "username": "admin", + "password_hash": "/VpEC5P5RjBe9KaKoGka3IAyqEImtOH4DX9pa4fGq0s=:KSkNZvZGhywj3xKzn1T39w==", + "is_admin": true, + "created_at": 1759575291 + } + ], + "reports": [ + { + "id": 1, + "name": "B1tK1ll3r", + "status": "Gemeldet", + "reported_by": "admin", + "confirmed_by": "", + "created_at": 1759580762, + "confirmed_at": 0 + } + ], + "audit": [ + { + "time": 1759580868, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759580762, + "actor": "admin", + "action": "report", + "details": "ID 1 · B1tK1ll3r", + "ip": "127.0.0.1" + }, + { + "time": 1759580750, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759580355, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759580347, + "actor": "admin", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759580244, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759579880, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759579770, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759579675, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + } + ], + "next_id": 2 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0af5b42 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/advocacy-watchlist + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ae3f1a --- /dev/null +++ b/main.go @@ -0,0 +1,771 @@ +// 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) +}