RC-1
This commit is contained in:
361
data.json
361
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
|
||||
}
|
||||
|
||||
692
main.go
692
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"}}
|
||||
<!doctype html>
|
||||
@@ -250,7 +248,7 @@ var baseTpl = `{{define "base"}}
|
||||
<title>{{block "title" .}}Meldesystem{{end}}</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#111827; --muted:#9ca3af; --text:#e5e7eb; --acc:#06b6d4; --ok:#10b981; --warn:#f59e0b; --err:#ef4444; }
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#0b1220,#0f172a);color:var(--text);font:16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial}
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#0b1220,#0f172a);color:var(--text);font:16px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
|
||||
a{color:var(--acc);text-decoration:none} a:hover{text-decoration:underline}
|
||||
.container{max-width:980px;margin:40px auto;padding:0 16px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
|
||||
@@ -282,6 +280,9 @@ var baseTpl = `{{define "base"}}
|
||||
<header>
|
||||
<div class="brand">Meldesystem</div>
|
||||
<div class="flex">
|
||||
{{if .IsAdmin}}<a class="badge" href="/users">👥 Nutzer</a>{{end}}
|
||||
<a class="badge" href="/audit">🪪 Audit</a>
|
||||
<a class="badge" href="/me">🔒 Passwort</a>
|
||||
<span class="badge">👤 {{.User}}</span>
|
||||
<form method="POST" action="/logout"><button class="secondary" type="submit">Logout</button></form>
|
||||
</div>
|
||||
@@ -325,14 +326,15 @@ var dashboardTpl = `{{define "dashboard"}}{{template "base" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="card grow">
|
||||
<h3 style="margin-top:0">Neuen Benutzernamen melden</h3>
|
||||
<form method="POST" action="/report" class="row">
|
||||
<input class="grow" type="text" name="name" placeholder="z.B. max.mustermann" required />
|
||||
<button type="submit">Melden</button>
|
||||
</form>
|
||||
<div class="spacer"></div>
|
||||
<div class="notice">Einträge starten mit Status <b>Gemeldet</b> und müssen von einem <u>anderen</u> Nutzer bestätigt werden.</div>
|
||||
</div>
|
||||
<h3 style="margin-top:0">Neuen Benutzernamen melden</h3>
|
||||
<form method="POST" action="/report" class="row">
|
||||
<input class="grow" type="text" name="name" placeholder="z.B. max.mustermann" required />
|
||||
<input class="grow" type="text" name="reason" placeholder="(optional) Grund zur Meldung" />
|
||||
<button type="submit">Melden</button>
|
||||
</form>
|
||||
<div class="spacer"></div>
|
||||
<div class="notice">Einträge starten mit Status <b>Gemeldet</b> und müssen von einem <u>anderen</u> Nutzer bestätigt werden.</div>
|
||||
</div>
|
||||
<div class="card grow">
|
||||
<h3 style="margin-top:0">Audit-Log</h3>
|
||||
<div class="muted">Letzte 8 Ereignisse</div>
|
||||
@@ -381,16 +383,50 @@ var dashboardTpl = `{{define "dashboard"}}{{template "base" .}}{{end}}
|
||||
{{if .Confirmed}}
|
||||
<div class="muted">bestätigt von {{.ConfirmedBy}} am {{.Confirmed}}</div>
|
||||
{{end}}
|
||||
|
||||
{{ $rid := .ID }} {{/* Report-ID vor dem Reasons-Range sichern */}}
|
||||
|
||||
{{if .Reasons}}
|
||||
<div style="margin-top:6px">
|
||||
<div class="muted">Gründe:</div>
|
||||
<ul style="margin:6px 0 0 18px; padding:0">
|
||||
{{range .Reasons}}
|
||||
<li>
|
||||
{{.Text}} <span class="muted">— {{.AddedBy}} · {{.AddedAt}}</span>
|
||||
{{if $.IsAdmin}}
|
||||
<form style="display:inline" method="POST" action="/reasons/delete" onsubmit="return confirm('Grund löschen?')">
|
||||
<input type="hidden" name="id" value="{{$rid}}" />
|
||||
<input type="hidden" name="rsid" value="{{.ID}}" />
|
||||
<button class="secondary" type="submit">Entfernen</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Grund hinzufügen -->
|
||||
<form method="POST" action="/reasons/add" style="margin-top:6px">
|
||||
<input type="hidden" name="id" value="{{.ID}}" />
|
||||
<input type="text" name="text" placeholder="Neuen Grund hinzufügen…" />
|
||||
<button class="secondary" type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
</td>
|
||||
<td class="right">
|
||||
{{if eq .Status "Gemeldet"}}
|
||||
<form style="display:inline" method="POST" action="/confirm">
|
||||
<input type="hidden" name="id" value="{{.ID}}" />
|
||||
<button type="submit">Bestätigen</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="badge">✔ Bereits bestätigt</span>
|
||||
{{end}}
|
||||
{{if ne $.User .ReportedBy}}
|
||||
<form style="display:inline" method="POST" action="/confirm">
|
||||
<input type="hidden" name="id" value="{{.ID}}" />
|
||||
<button type="submit">Bestätigen</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="badge">⏳ Wartet auf Bestätigung durch andere</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="badge">✔ Bereits bestätigt</span>
|
||||
{{end}}
|
||||
{{if $.IsAdmin}}
|
||||
<form style="display:inline" method="POST" action="/delete" onsubmit="return confirm('Wirklich löschen?')">
|
||||
<input type="hidden" name="id" value="{{.ID}}" />
|
||||
@@ -415,34 +451,26 @@ var auditTpl = `{{define "audit_page"}}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Audit · Meldesystem</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#111827; --muted:#9ca3af; --text:#e5e7eb; --acc:#06b6d4; --ok:#10b981; --warn:#f59e0b; --err:#ef4444; }
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#0b1220,#0f172a);color:var(--text);font:16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial}
|
||||
a{color:var(--acc);text-decoration:none} a:hover{text-decoration:underline}
|
||||
:root { --bg:#0f172a; --card:#111827; --muted:#9ca3af; --text:#e5e7eb; --acc:#06b6d4; }
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#0b1220,#0f172a);color:var(--text);font:16px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
|
||||
.container{max-width:980px;margin:40px auto;padding:0 16px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
|
||||
.brand{font-weight:800;font-size:20px;letter-spacing:.2px}
|
||||
.card{background:var(--card);border:1px solid #1f2937;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.25);padding:20px;margin-bottom:16px}
|
||||
.card{background:#111827;border:1px solid #1f2937;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.25);padding:20px;margin-bottom:16px}
|
||||
table{width:100%;border-collapse:separate;border-spacing:0}
|
||||
th,td{padding:12px 10px;border-bottom:1px solid #1f2937;font-size:14px}
|
||||
th{text-align:left;color:var(--muted);font-weight:600}
|
||||
.muted{color:var(--muted)}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#0b1220;border:1px solid #1f2937;color:var(--muted);font-size:12px}
|
||||
th{text-align:left;color:#9ca3af;font-weight:600}
|
||||
.muted{color:#9ca3af}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#0b1220;border:1px solid #1f2937;color:#9ca3af;font-size:12px}
|
||||
.flex{display:flex;gap:8px;align-items:center}
|
||||
button{padding:10px 14px;border:0;border-radius:12px;background:var(--acc);color:#001018;font-weight:700;cursor:pointer}
|
||||
button.secondary{background:#1f2937;color:var(--text)}
|
||||
footer{margin-top:24px;color:var(--muted);font-size:12px;text-align:center}
|
||||
button{padding:10px 14px;border:0;border-radius:12px;background:#06b6d4;color:#001018;font-weight:700;cursor:pointer}
|
||||
.secondary{background:#1f2937;color:#e5e7eb}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="brand">Meldesystem</div>
|
||||
<div class="flex">
|
||||
<span class="badge">👤 {{.User}}</span>
|
||||
<form method="GET" action="/"><button class="secondary" type="submit">Zurück</button></form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex" style="justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<a class="badge" href="/">⬅ Zurück</a>
|
||||
<span class="badge">👤 {{.User}}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin:0 0 8px">Audit-Log (vollständig)</h3>
|
||||
<table>
|
||||
@@ -462,8 +490,111 @@ var auditTpl = `{{define "audit_page"}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}`
|
||||
|
||||
<footer>Ohne externe Assets · Zeit: {{.Now}}</footer>
|
||||
var usersTpl = `{{define "users_page"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nutzerverwaltung · Meldesystem</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#111827; --muted:#9ca3af; --text:#e5e7eb; --acc:#06b6d4; --err:#ef4444; }
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#0b1220,#0f172a);color:var(--text);font:16px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
|
||||
.container{max-width:980px;margin:40px auto;padding:0 16px}
|
||||
.card{background:#111827;border:1px solid #1f2937;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.25);padding:20px;margin-bottom:16px}
|
||||
table{width:100%;border-collapse:separate;border-spacing:0}
|
||||
th,td{padding:12px 10px;border-bottom:1px solid #1f2937;font-size:14px}
|
||||
th{text-align:left;color:#9ca3af;font-weight:600}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||
input[type=text], input[type=password]{padding:12px 14px;border:1px solid #273244;border-radius:12px;background:#0b1220;color:#e5e7eb}
|
||||
button{padding:10px 14px;border:0;border-radius:12px;background:#06b6d4;color:#001018;font-weight:700;cursor:pointer}
|
||||
.secondary{background:#1f2937;color:#e5e7eb}
|
||||
.danger{background:#ef4444;color:#fff}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#0b1220;border:1px solid #1f2937;color:#9ca3af;font-size:12px}
|
||||
.muted{color:#9ca3af}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<h3 style="margin:0">Nutzerverwaltung</h3>
|
||||
<div class="row">
|
||||
<a class="badge" href="/">⬅ Zurück</a>
|
||||
<span class="badge">👤 {{.User}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<form class="row" method="POST" action="/users/create">
|
||||
<input name="username" placeholder="neuer Nutzername" required />
|
||||
<input type="password" name="password" placeholder="Passwort" required />
|
||||
<label class="badge"><input type="checkbox" name="is_admin" value="1" /> Admin</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Nutzer</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{if .IsAdmin}}Admin{{else}}Nutzer{{end}}</td>
|
||||
<td class="muted">{{.Created}}</td>
|
||||
<td>
|
||||
<form style="display:inline" method="POST" action="/users/resetpw" onsubmit="return confirm('Passwort wirklich zurücksetzen?')">
|
||||
<input type="hidden" name="username" value="{{.Username}}" />
|
||||
<button class="secondary" type="submit">Passwort zurücksetzen</button>
|
||||
</form>
|
||||
<form style="display:inline" method="POST" action="/users/toggleadmin">
|
||||
<input type="hidden" name="username" value="{{.Username}}" />
|
||||
<button class="secondary" type="submit">Rolle wechseln</button>
|
||||
</form>
|
||||
<form style="display:inline" method="POST" action="/users/delete" onsubmit="return confirm('Nutzer wirklich löschen?')">
|
||||
<input type="hidden" name="username" value="{{.Username}}" />
|
||||
<button class="danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="muted">Keine Nutzer vorhanden</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .Msg}}<div class="muted">{{.Msg}}</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}`
|
||||
|
||||
var meTpl = `{{define "me_page"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Passwort ändern · Meldesystem</title>
|
||||
<style>*{box-sizing:border-box} body{margin:0;display:grid;place-items:center;height:100vh;background:linear-gradient(180deg,#0b1220,#0f172a);color:#e5e7eb;font:16px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial} .card{background:#111827;border:1px solid #1f2937;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.25);padding:24px;width:min(480px,94vw)} input{width:100%;max-width:100%;display:block;border:1px solid #273244;border-radius:12px;background:#0b1220;color:#e5e7eb;padding:12px 14px;margin:6px 0} button{width:100%;margin-top:10px;padding:12px;border:0;border-radius:12px;background:#06b6d4;color:#001018;font-weight:800;cursor:pointer} .row{display:flex;gap:8px;justify-content:space-between} a{color:#06b6d4}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<a href="/">⬅ Zurück</a>
|
||||
<div>👤 {{.User}}</div>
|
||||
</div>
|
||||
<h2 style="margin:6px 0 12px">Passwort ändern</h2>
|
||||
<form method="POST" action="/me">
|
||||
<input type="password" name="old" placeholder="Altes Passwort" required />
|
||||
<input type="password" name="new" placeholder="Neues Passwort" required />
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
{{if .Msg}}<div style="margin-top:8px">{{.Msg}}</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 =======
|
||||
|
||||
Reference in New Issue
Block a user