From eff64482a10284491947ac71d354813921b0026a Mon Sep 17 00:00:00 2001 From: jbergner Date: Sat, 4 Oct 2025 18:05:27 +0200 Subject: [PATCH] RC-1 --- data.json | 361 +++++++++++++++++++++++----- main.go | 692 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 886 insertions(+), 167 deletions(-) diff --git a/data.json b/data.json index fbc288e..4429b66 100644 --- a/data.json +++ b/data.json @@ -2,86 +2,339 @@ "users": [ { "username": "admin", - "password_hash": "/VpEC5P5RjBe9KaKoGka3IAyqEImtOH4DX9pa4fGq0s=:KSkNZvZGhywj3xKzn1T39w==", + "password_hash": "hBaSLUNhYTfD8t5QgMBAyRgb/IsdpOzG70XzIaPWPik=:LeiXx83tVF0Vb08KjNwT/A==", "is_admin": true, - "created_at": 1759575291 + "created_at": 1759592823 + }, + { + "username": "jbergner", + "password_hash": "xO0T3HChR2wCqr2/EdGBh5DImelVl1WQZ7pji+MFW84=:yhjVxufYiqdzq0XQZBcRtQ==", + "is_admin": false, + "created_at": 1759593689 } ], "reports": [ { - "id": 1, + "id": 3, "name": "B1tK1ll3r", - "status": "Gemeldet", + "status": "Bestätigt", "reported_by": "admin", - "confirmed_by": "", - "created_at": 1759580762, - "confirmed_at": 0 + "confirmed_by": "jbergner", + "created_at": 1759593661, + "confirmed_at": 1759593901, + "reasons": [ + { + "id": 4, + "text": "Trottel", + "added_by": "admin", + "added_at": 1759593661 + } + ] } ], "audit": [ { - "time": 1759580868, - "actor": "admin", + "time": 1759593901, + "actor": "jbergner", + "action": "confirm", + "details": "ID 3 · B1tK1ll3r", + "ip": "127.0.0.1" + }, + { + "time": 1759593892, + "actor": "jbergner", + "action": "pw_change", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593884, + "actor": "jbergner", "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, + "time": 1759593880, "actor": "", "action": "logout", "details": "", "ip": "127.0.0.1" }, { - "time": 1759579675, + "time": 1759593872, + "actor": "admin", + "action": "user_resetpw", + "details": "jbergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593867, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593779, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593771, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593765, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593752, + "actor": "admin", + "action": "user_resetpw", + "details": "jbergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593745, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593739, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593734, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593715, + "actor": "admin", + "action": "user_resetpw", + "details": "jbergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593710, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593702, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593697, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593692, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593689, + "actor": "admin", + "action": "user_create", + "details": "jbergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593682, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593677, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593672, + "actor": "jbergner", + "action": "login", + "details": "Fehlgeschlagen", + "ip": "127.0.0.1" + }, + { + "time": 1759593668, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593661, + "actor": "admin", + "action": "report", + "details": "ID 3 · B1tK1ll3r", + "ip": "127.0.0.1" + }, + { + "time": 1759593652, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593441, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593436, + "actor": "", + "action": "logout", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593433, + "actor": "admin", + "action": "pw_change", + "details": "", + "ip": "127.0.0.1" + }, + { + "time": 1759593403, + "actor": "admin", + "action": "delete", + "details": "ID 1 · B1tK1ll3r", + "ip": "127.0.0.1" + }, + { + "time": 1759593401, + "actor": "admin", + "action": "delete", + "details": "ID 2 · Jan Bergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593393, + "actor": "admin", + "action": "reason_delete", + "details": "Report 2, Reason 3", + "ip": "127.0.0.1" + }, + { + "time": 1759593388, + "actor": "admin", + "action": "report", + "details": "ID 2 · Jan Bergner", + "ip": "127.0.0.1" + }, + { + "time": 1759593380, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759593252, + "actor": "admin", + "action": "reason_delete", + "details": "Report 1, Reason 1", + "ip": "127.0.0.1" + }, + { + "time": 1759593250, + "actor": "admin", + "action": "reason_delete", + "details": "Report 1, Reason 2", + "ip": "127.0.0.1" + }, + { + "time": 1759593246, + "actor": "admin", + "action": "login", + "details": "Erfolg", + "ip": "127.0.0.1" + }, + { + "time": 1759592953, + "actor": "admin", + "action": "reason_delete_failed", + "details": "Report 0, Reason 2", + "ip": "127.0.0.1" + }, + { + "time": 1759592948, + "actor": "admin", + "action": "reason_delete_failed", + "details": "Report 0, Reason 1", + "ip": "127.0.0.1" + }, + { + "time": 1759592946, + "actor": "admin", + "action": "reason_add", + "details": "Report 1", + "ip": "127.0.0.1" + }, + { + "time": 1759592871, + "actor": "admin", + "action": "reason_delete_failed", + "details": "Report 0, Reason 1", + "ip": "127.0.0.1" + }, + { + "time": 1759592869, + "actor": "admin", + "action": "reason_add", + "details": "Report 1", + "ip": "127.0.0.1" + }, + { + "time": 1759592866, + "actor": "admin", + "action": "report", + "details": "ID 1 · B1tK1ll3r", + "ip": "127.0.0.1" + }, + { + "time": 1759592833, "actor": "admin", "action": "login", "details": "Erfolg", "ip": "127.0.0.1" } ], - "next_id": 2 + "next_id": 4, + "next_rid": 5 } diff --git a/main.go b/main.go index 6ae3f1a..06cf13e 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,12 @@ // 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. +// Web-App mit Login, Persistenz (JSON-Datei), Audit-Log, Nutzerverwaltung +// und UI ohne externe Verweise. Benutzer können "Benutzernamen" melden; +// Einträge starten mit Status "Gemeldet" und müssen von einem anderen Nutzer +// bestätigt werden. Alles wird auditiert. // -// Hinweise: Für Produktion TLS, CSRF, bcrypt/argon2id etc. ergänzen. +// PRODUKTIONSHINWEISE: HTTPS erzwingen (Secure-Cookie), CSRF-Schutz, +// bcrypt/argon2id statt Demo-Hash, Rate Limiting, echte DB, Passwort-Richtlinien. package main @@ -16,6 +17,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "html/template" "log" @@ -46,13 +48,21 @@ const ( ) 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"` + 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"` + Reasons []Reason `json:"reasons"` +} + +type Reason struct { + ID int64 `json:"id"` + Text string `json:"text"` + AddedBy string `json:"added_by"` + AddedAt int64 `json:"added_at"` } type AuditEntry struct { @@ -68,6 +78,7 @@ type DB struct { Reports []Report `json:"reports"` Audit []AuditEntry `json:"audit"` NextID int64 `json:"next_id"` + NextRID int64 `json:"next_rid"` } // ======= Globale Variablen ======= @@ -75,7 +86,7 @@ type DB struct { var ( dataPath = envOr("DATA_FILE", "data.json") cookieName = envOr("COOKIE_NAME", "session") - secretKey []byte // für HMAC der Session-Cookies + secretKey []byte // HMAC für Session-Cookies mu sync.Mutex db DB @@ -83,7 +94,7 @@ var ( tpl *template.Template ) -// ======= Hilfsfunktionen ======= +// ======= Helpers: Datei + Env ======= func envOr(k, def string) string { if v := os.Getenv(k); v != "" { @@ -95,18 +106,17 @@ func envOr(k, def string) string { func loadDB() error { mu.Lock() defer mu.Unlock() - f, err := os.Open(dataPath) if err != nil { if os.IsNotExist(err) { - db = DB{NextID: 1} + db = DB{NextID: 1, NextRID: 1} return saveDB() } return err } defer f.Close() - decoder := json.NewDecoder(f) - return decoder.Decode(&db) + dec := json.NewDecoder(f) + return dec.Decode(&db) } func saveDB() error { @@ -125,14 +135,13 @@ func saveDB() error { return os.Rename(f.Name(), dataPath) } -// ======= Pseudo-PBKDF (nur Demo!) ======= -// Für Produktion: bcrypt/argon2id verwenden. +// ======= Passwort-Hash (Demo!) ======= 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 + for i := 0; i < 100_000; i++ { h.Write([]byte{byte(i)}) } return base64.StdEncoding.EncodeToString(h.Sum(nil)) + ":" + base64.StdEncoding.EncodeToString(salt) @@ -150,9 +159,13 @@ func verifyPassword(pw, stored string) bool { return hashPassword(pw, salt) == stored } -func newSalt() []byte { b := make([]byte, 16); crand.Read(b); return b } +func newSalt() []byte { b := make([]byte, 16); _, _ = crand.Read(b); return b } -// ======= Sessions (signiertes Cookie) ======= +// ======= Sessions ======= + +type ctxKey string + +var userKey ctxKey = "user" type Session struct { Username string @@ -169,14 +182,7 @@ 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, - }) + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: value, Path: "/", HttpOnly: true, Secure: false, SameSite: http.SameSiteLaxMode}) } func clearSession(w http.ResponseWriter) { @@ -199,8 +205,7 @@ func getSession(r *http.Request) (string, bool) { if sign(payloadB) != parts[1] { return "", false } - payload := string(payloadB) - sp := strings.Split(payload, "|") + sp := strings.Split(string(payloadB), "|") if len(sp) != 2 { return "", false } @@ -211,12 +216,6 @@ func getSession(r *http.Request) (string, bool) { 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) @@ -224,8 +223,7 @@ func withAuth(h http.HandlerFunc) http.HandlerFunc { http.Redirect(w, r, "/login", http.StatusFound) return } - ctx := context.WithValue(r.Context(), userKey, u) - r = r.WithContext(ctx) + r = r.WithContext(context.WithValue(r.Context(), userKey, u)) h(w, r) } } @@ -239,7 +237,7 @@ func currentUser(r *http.Request) string { return "" } -// ======= Template & Assets ======= +// ======= Templates ======= var baseTpl = `{{define "base"}} @@ -250,7 +248,7 @@ var baseTpl = `{{define "base"}} {{block "title" .}}Meldesystem{{end}}
-
-
Meldesystem
-
- 👤 {{.User}} -
-
-
- +
+ ⬅ Zurück + 👤 {{.User}} +

Audit-Log (vollständig)

@@ -462,8 +490,111 @@ var auditTpl = `{{define "audit_page"}}
+
+ + +{{end}}` - +var usersTpl = `{{define "users_page"}} + + + + + + Nutzerverwaltung · Meldesystem + + + +
+
+
+

Nutzerverwaltung

+
+ ⬅ Zurück + 👤 {{.User}} +
+
+
+ + + + +
+
+ +
+ + + + {{range .Users}} + + + + + + + {{else}} + + {{end}} + +
NutzerRolleErstelltAktionen
{{.Username}}{{if .IsAdmin}}Admin{{else}}Nutzer{{end}}{{.Created}} +
+ + +
+
+ + +
+
+ + +
+
Keine Nutzer vorhanden
+
+ {{if .Msg}}
{{.Msg}}
{{end}} +
+ + +{{end}}` + +var meTpl = `{{define "me_page"}} + + + + + + Passwort ändern · Meldesystem + + + +
+
+ ⬅ Zurück +
👤 {{.User}}
+
+

Passwort ändern

+
+ + + +
+ {{if .Msg}}
{{.Msg}}
{{end}}
@@ -487,18 +618,27 @@ func main() { 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)) + template.Must(tpl.New("users_bundle").Parse(usersTpl)) + template.Must(tpl.New("me_bundle").Parse(meTpl)) http.HandleFunc("/", withAuth(handleIndex)) + http.HandleFunc("/reasons/add", withAuth(handleReasonAdd)) + http.HandleFunc("/reasons/delete", withAuth(handleReasonDelete)) http.HandleFunc("/report", withAuth(handleReport)) http.HandleFunc("/confirm", withAuth(handleConfirm)) http.HandleFunc("/delete", withAuth(handleDelete)) http.HandleFunc("/audit", withAuth(handleAudit)) + http.HandleFunc("/users", withAuth(handleUsers)) + http.HandleFunc("/users/create", withAuth(handleUserCreate)) + http.HandleFunc("/users/resetpw", withAuth(handleUserResetPW)) + http.HandleFunc("/users/toggleadmin", withAuth(handleUserToggleAdmin)) + http.HandleFunc("/users/delete", withAuth(handleUserDelete)) + http.HandleFunc("/me", withAuth(handleMe)) http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { @@ -589,7 +729,7 @@ 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 + db.Audit = append([]AuditEntry{entry}, db.Audit...) if len(db.Audit) > 5000 { db.Audit = db.Audit[:5000] } @@ -603,7 +743,114 @@ func clientIP(r *http.Request) string { return strings.Split(r.RemoteAddr, ":")[0] } -// ======= Handlers ======= +func recentAudit(n int) []map[string]any { + mu.Lock() + defer mu.Unlock() + if n > len(db.Audit) { + n = len(db.Audit) + } + out := make([]map[string]any, 0, n) + for _, a := range db.Audit[:n] { + 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 +} + +// ======= Benutzerverwaltung (Logik) ======= + +func findUser(username string) (int, *User) { + for i := range db.Users { + if db.Users[i].Username == username { + return i, &db.Users[i] + } + } + return -1, nil +} + +func getUser(username string) (User, bool) { + mu.Lock() + defer mu.Unlock() + for _, u := range db.Users { + if u.Username == username { + return u, true + } + } + return User{}, false +} + +func countAdmins() int { + c := 0 + for _, u := range db.Users { + if u.IsAdmin { + c++ + } + } + return c +} + +func createUser(username, password string, isAdmin bool) error { + mu.Lock() + defer mu.Unlock() + if username == "" || password == "" { + return errors.New("leer") + } + if _, u := findUser(username); u != nil { + return errors.New("existiert bereits") + } + u := User{Username: username, PasswordHash: hashPassword(password, newSalt()), IsAdmin: isAdmin, CreatedAt: time.Now().Unix()} + db.Users = append(db.Users, u) + return saveDB() +} + +func setPassword(username, newpw string) error { + mu.Lock() + defer mu.Unlock() + idx, u := findUser(username) + if u == nil { + return errors.New("nicht gefunden") + } + db.Users[idx].PasswordHash = hashPassword(newpw, newSalt()) + return saveDB() +} + +func setAdmin(username string, admin bool) error { + mu.Lock() + defer mu.Unlock() + idx, u := findUser(username) + if u == nil { + return errors.New("nicht gefunden") + } + if !admin && u.IsAdmin && countAdmins() == 1 { + return errors.New("letzter Admin darf nicht entfernt werden") + } + db.Users[idx].IsAdmin = admin + return saveDB() +} + +func deleteUser(username, actor string) error { + mu.Lock() + defer mu.Unlock() + idx, u := findUser(username) + if u == nil { + return errors.New("nicht gefunden") + } + if u.IsAdmin && countAdmins() == 1 { + return errors.New("letzter Admin darf nicht gelöscht werden") + } + if username == actor { + return errors.New("eigener Account kann nicht gelöscht werden") + } + db.Users = append(db.Users[:idx], db.Users[idx+1:]...) + return saveDB() +} + +// ======= HTTP-Handler ======= func handleIndex(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) @@ -629,18 +876,22 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { m["Confirmed"] = time.Unix(rep.ConfirmedAt, 0).Format("02.01.2006 15:04") m["ConfirmedBy"] = rep.ConfirmedBy } + if len(rep.Reasons) > 0 { + rs := make([]map[string]any, 0, len(rep.Reasons)) + for _, rsn := range rep.Reasons { + rs = append(rs, map[string]any{ + "ID": rsn.ID, + "Text": template.HTMLEscapeString(rsn.Text), + "AddedBy": rsn.AddedBy, + "AddedAt": time.Unix(rsn.AddedAt, 0).Format("02.01.2006 15:04"), + }) + } + m["Reasons"] = rs + } 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"), - } + data := map[string]any{"User": currentUser(r), "IsAdmin": isAdmin(currentUser(r)), "Reports": rows, "Q": q, "RecentAudit": recentAudit(8), "Now": time.Now().Format("02.01.2006 15:04")} _ = tpl.ExecuteTemplate(w, "dashboard", data) } @@ -650,6 +901,7 @@ func handleReport(w http.ResponseWriter, r *http.Request) { return } name := strings.TrimSpace(r.FormValue("name")) + reasonText := strings.TrimSpace(r.FormValue("reason")) if name == "" { http.Redirect(w, r, "/?msg=leer", http.StatusFound) return @@ -667,7 +919,16 @@ func handleReport(w http.ResponseWriter, r *http.Request) { } id := db.NextID db.NextID++ - rep := Report{ID: id, Name: name, Status: StatusGemeldet, ReportedBy: user, CreatedAt: time.Now().Unix()} + rep := Report{ + ID: id, Name: name, Status: StatusGemeldet, ReportedBy: user, CreatedAt: time.Now().Unix(), + } + if reasonText != "" { + rid := db.NextRID + db.NextRID++ + rep.Reasons = []Reason{{ + ID: rid, Text: reasonText, AddedBy: user, AddedAt: time.Now().Unix(), + }} + } db.Reports = append([]Report{rep}, db.Reports...) _ = saveDB() mu.Unlock() @@ -676,6 +937,87 @@ func handleReport(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } +func handleReasonAdd(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + user := currentUser(r) + rid, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + http.Redirect(w, r, "/?msg=reason_empty", http.StatusFound) + return + } + + var ok bool + mu.Lock() + for i := range db.Reports { + if db.Reports[i].ID == rid { + // optional: Limit/Validierung + if len(db.Reports[i].Reasons) > 50 { + break + } + id := db.NextRID + db.NextRID++ + db.Reports[i].Reasons = append(db.Reports[i].Reasons, Reason{ + ID: id, Text: text, AddedBy: user, AddedAt: time.Now().Unix(), + }) + _ = saveDB() + ok = true + break + } + } + mu.Unlock() + + if ok { + appendAudit(user, "reason_add", fmt.Sprintf("Report %d", rid), r) + } else { + appendAudit(user, "reason_add_failed", fmt.Sprintf("Report %d", rid), r) + } + http.Redirect(w, r, "/", http.StatusFound) +} + +func handleReasonDelete(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 + } + user := currentUser(r) + + rid, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) // Report-ID + rsid, _ := strconv.ParseInt(r.FormValue("rsid"), 10, 64) // Reason-ID + + var ok bool + mu.Lock() + for i := range db.Reports { + if db.Reports[i].ID == rid { + rs := db.Reports[i].Reasons + for j := range rs { + if rs[j].ID == rsid { + db.Reports[i].Reasons = append(rs[:j], rs[j+1:]...) + _ = saveDB() + ok = true + break + } + } + break + } + } + mu.Unlock() + + if ok { + appendAudit(user, "reason_delete", fmt.Sprintf("Report %d, Reason %d", rid, rsid), r) + } else { + appendAudit(user, "reason_delete_failed", fmt.Sprintf("Report %d, Reason %d", rid, rsid), r) + } + http.Redirect(w, r, "/", http.StatusFound) +} + func handleConfirm(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) @@ -684,13 +1026,16 @@ func handleConfirm(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) user := currentUser(r) + var did bool + var name string mu.Lock() - defer mu.Unlock() for i, rep := range db.Reports { if rep.ID == id { - if rep.ReportedBy == user { // nicht selbst bestätigen + if rep.ReportedBy == user { + mu.Unlock() appendAudit(user, "confirm_denied", fmt.Sprintf("ID %d · eigener Eintrag", id), r) - break + http.Redirect(w, r, "/", http.StatusFound) + return } if rep.Status == StatusBestaetigt { break @@ -698,12 +1043,17 @@ func handleConfirm(w http.ResponseWriter, r *http.Request) { rep.Status = StatusBestaetigt rep.ConfirmedBy = user rep.ConfirmedAt = time.Now().Unix() + name = rep.Name db.Reports[i] = rep _ = saveDB() - appendAudit(user, "confirm", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r) + did = true break } } + mu.Unlock() + if did { + appendAudit(user, "confirm", fmt.Sprintf("ID %d · %s", id, name), r) + } http.Redirect(w, r, "/", http.StatusFound) } @@ -719,16 +1069,22 @@ func handleDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) user := currentUser(r) + var name string + var ok bool mu.Lock() - defer mu.Unlock() for i, rep := range db.Reports { if rep.ID == id { + name = rep.Name db.Reports = append(db.Reports[:i], db.Reports[i+1:]...) _ = saveDB() - appendAudit(user, "delete", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r) + ok = true break } } + mu.Unlock() + if ok { + appendAudit(user, "delete", fmt.Sprintf("ID %d · %s", id, name), r) + } http.Redirect(w, r, "/", http.StatusFound) } @@ -738,24 +1094,134 @@ func handleAudit(w http.ResponseWriter, r *http.Request) { _ = tpl.ExecuteTemplate(w, "audit_page", data) } -func recentAudit(n int) []map[string]any { +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if !isAdmin(currentUser(r)) { + w.WriteHeader(http.StatusForbidden) + return false + } + return true +} + +func handleUsers(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } mu.Lock() - defer mu.Unlock() - limit := n - if len(db.Audit) < n { - limit = len(db.Audit) + rows := make([]map[string]any, 0, len(db.Users)) + for _, u := range db.Users { + rows = append(rows, map[string]any{"Username": u.Username, "IsAdmin": u.IsAdmin, "Created": time.Unix(u.CreatedAt, 0).Format("02.01.2006 15:04")}) } - 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, - }) + mu.Unlock() + _ = tpl.ExecuteTemplate(w, "users_page", map[string]any{"User": currentUser(r), "Users": rows}) +} + +func handleUserCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return } - return out + if !requireAdmin(w, r) { + return + } + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + isAdmin := r.FormValue("is_admin") == "1" + if err := createUser(username, password, isAdmin); err != nil { + appendAudit(currentUser(r), "user_create_failed", err.Error(), r) + } else { + appendAudit(currentUser(r), "user_create", username, r) + } + http.Redirect(w, r, "/users", http.StatusFound) +} + +func handleUserResetPW(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !requireAdmin(w, r) { + return + } + username := r.FormValue("username") + newpw := randomPassword(12) + fmt.Println(newpw) + if err := setPassword(username, newpw); err != nil { + appendAudit(currentUser(r), "user_resetpw_failed", username+": "+err.Error(), r) + } else { + appendAudit(currentUser(r), "user_resetpw", username, r) + } + http.Redirect(w, r, "/users", http.StatusFound) +} + +func handleUserToggleAdmin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !requireAdmin(w, r) { + return + } + username := r.FormValue("username") + u, ok := getUser(username) + if !ok { + http.Redirect(w, r, "/users", http.StatusFound) + return + } + if err := setAdmin(username, !u.IsAdmin); err != nil { + appendAudit(currentUser(r), "user_toggle_admin_failed", username+": "+err.Error(), r) + } else { + appendAudit(currentUser(r), "user_toggle_admin", username, r) + } + http.Redirect(w, r, "/users", http.StatusFound) +} + +func handleUserDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !requireAdmin(w, r) { + return + } + username := r.FormValue("username") + if err := deleteUser(username, currentUser(r)); err != nil { + appendAudit(currentUser(r), "user_delete_failed", username+": "+err.Error(), r) + } else { + appendAudit(currentUser(r), "user_delete", username, r) + } + http.Redirect(w, r, "/users", http.StatusFound) +} + +func handleMe(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = tpl.ExecuteTemplate(w, "me_page", map[string]any{"User": currentUser(r)}) + return + } + if r.Method == http.MethodPost { + old := r.FormValue("old") + newp := r.FormValue("new") + user := currentUser(r) + mu.Lock() + idx, u := findUser(user) + if u == nil { + mu.Unlock() + w.WriteHeader(http.StatusUnauthorized) + return + } + if !verifyPassword(old, u.PasswordHash) { + mu.Unlock() + appendAudit(user, "pw_change_failed", "falsches altes Passwort", r) + _ = tpl.ExecuteTemplate(w, "me_page", map[string]any{"User": user, "Msg": "Altes Passwort falsch"}) + return + } + db.Users[idx].PasswordHash = hashPassword(newp, newSalt()) + _ = saveDB() + mu.Unlock() + appendAudit(user, "pw_change", "", r) + _ = tpl.ExecuteTemplate(w, "me_page", map[string]any{"User": user, "Msg": "Gespeichert"}) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) } // ======= Utilities =======