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
+
+ | Zeit | Aktion | Details |
+
+ {{range .RecentAudit}}
+
+ | {{.When}} |
+ {{.Action}} |
+ {{.Details}} |
+
+ {{else}}
+ | Noch keine Ereignisse |
+ {{end}}
+
+
+
+
+
+
+
+
Gemeldete Benutzernamen
+
+
+
+
+ | ID | Name | Status | Aktionen |
+
+
+ {{range .Reports}}
+
+ | #{{.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}}
+ |
+
+ {{else}}
+ | Keine Einträge |
+ {{end}}
+
+
+
+{{end}}`
+
+var auditTpl = `{{define "audit_page"}}
+
+
+
+
+
+ Audit · Meldesystem
+
+
+
+
+
+ Meldesystem
+
+ 👤 {{.User}}
+
+
+
+
+
+
Audit-Log (vollständig)
+
+ | Zeit | Nutzer | Aktion | Details | IP |
+
+ {{range .Rows}}
+
+ | {{.When}} |
+ {{.Actor}} |
+ {{.Action}} |
+ {{.Details}} |
+ {{.IP}} |
+
+ {{else}}
+ | Noch keine Ereignisse |
+ {{end}}
+
+
+
+
+
+
+
+
+{{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)
+}