Files
advocacy-watchlist/main.go
jbergner c094b36af9
Some checks failed
release-tag / release-image (push) Successful in 4m8s
build-binaries / build (, amd64, linux) (push) Failing after 40s
build-binaries / build (, arm, 7, linux) (push) Failing after 37s
build-binaries / build (, arm64, linux) (push) Failing after 37s
build-binaries / build (.exe, amd64, windows) (push) Failing after 36s
build-binaries / release (push) Has been skipped
v1.0.0
2025-10-05 10:01:28 +02:00

1341 lines
42 KiB
Go

// main.go
// Go 1.21+
// 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.
//
// PRODUKTIONSHINWEISE: HTTPS erzwingen (Secure-Cookie), CSRF-Schutz,
// bcrypt/argon2id statt Demo-Hash, Rate Limiting, echte DB, Passwort-Richtlinien.
package main
import (
"context"
"crypto/hmac"
crand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"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"`
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 {
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"`
NextRID int64 `json:"next_rid"`
}
// ======= Globale Variablen =======
var (
dataPath = envOr("DATA_FILE", "data.json")
cookieName = envOr("COOKIE_NAME", "session")
secretKey []byte // HMAC für Session-Cookies
mu sync.Mutex
db DB
tpl *template.Template
)
// ======= Helpers: Datei + Env =======
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, NextRID: 1}
return saveDB()
}
return err
}
defer f.Close()
dec := json.NewDecoder(f)
return dec.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)
}
// ======= Passwort-Hash (Demo!) =======
func hashPassword(pw string, salt []byte) string {
h := sha256.New()
h.Write(salt)
h.Write([]byte(pw))
for i := 0; i < 100_000; i++ {
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 =======
type ctxKey string
var userKey ctxKey = "user"
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, 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
}
sp := strings.Split(string(payloadB), "|")
if len(sp) != 2 {
return "", false
}
exp, _ := strconv.ParseInt(sp[1], 10, 64)
if time.Now().Unix() > exp {
return "", false
}
return sp[0], true
}
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
}
r = r.WithContext(context.WithValue(r.Context(), userKey, u))
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 ""
}
// ======= Templates =======
var baseTpl = `{{define "base"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{block "title" .}}Advocacy Watchlist{{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}
a{color:var(--acc);text-decoration:none} a:hover{text-decoration:underline}
.container{max-width:1200px;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}
.row{display:flex;gap:16px;flex-wrap:wrap}
.grow{flex:1 1 300px}
input[type=text], input[type=password]{width:100%;padding:12px 14px;border:1px solid #273244;border-radius:12px;background:#0b1220;color:var(--text)}
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)}
.status{font-size:12px;padding:4px 8px;border-radius:999px;border:1px solid #2b374a}
.status.gemeldet{background:#0b1220;color:var(--warn)}
.status.bestaetigt{background:#0b1220;color:var(--ok)}
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}
.right{text-align:right}
.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}
.flex{display:flex;gap:8px;align-items:center}
.spacer{height:8px}
.danger{background:var(--err);color:#fff}
.notice{padding:10px;border:1px dashed #334155;border-radius:12px;color:var(--muted)}
footer{margin-top:24px;color:var(--muted);font-size:12px;text-align:center}
</style>
</head>
<body>
<div class="container">
<header>
<div class="brand">Advocacy Watchlist</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>
</header>
{{template "content" .}}
<footer>Advocacy Watchlist · Zeit: {{.Now}}</footer>
</div>
</body>
</html>
{{end}}`
var loginTpl = `{{define "login"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login · Advocacy Watchlist</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(420px,94vw)}
input{width:100%;max-width:100%;display:block;margin:0;border:1px solid #273244;border-radius:12px;background:#0b1220;color:#e5e7eb;padding:12px 14px}
label{display:block;margin:8px 0 6px;color:#9ca3af}
button{width:100%;margin-top:14px;padding:12px;border:0;border-radius:12px;background:#06b6d4;color:#001018;font-weight:800;cursor:pointer}
.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} <!-- NEU -->
.muted{color:#9ca3af;font-size:12px;text-align:center;margin-top:10px}
</style>
</head>
<body>
<div class="card">
<h2 style="margin:0 0 8px">Anmeldung</h2>
<div style="margin-top:12px;text-align:center">
<a class="badge" href="/public">🌐 View public Entries</a>
</div>
<form method="POST" action="/login">
<label>Nutzername</label>
<input name="username" required />
<label>Passwort</label>
<input type="password" name="password" required />
<button type="submit">Einloggen</button>
</form>
{{if .Msg}}<div class="muted">{{.Msg}}</div>{{end}}
{{if .Setup}}
<div class="muted" style="margin-top:12px">Erstbenutzer: <b>{{.SetupUser}}</b> / Passwort: <b>{{.SetupPass}}</b> (bitte nach dem Login ändern)</div>
{{end}}
</div>
</body>
</html>
{{end}}`
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 />
<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>
<table>
<thead><tr><th>Zeit</th><th>Aktion</th><th>Details</th></tr></thead>
<tbody>
{{range .RecentAudit}}
<tr>
<td class="muted">{{.When}}</td>
<td>{{.Action}}</td>
<td class="muted">{{.Details}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Noch keine Ereignisse</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="row" style="align-items:center;justify-content:space-between">
<h3 style="margin:0">Gemeldete Benutzernamen</h3>
<form method="GET" action="/" class="row">
<input name="q" placeholder="Suche…" value="{{.Q}}" />
<button class="secondary" type="submit">Filtern</button>
<a class="badge" href="/audit">Gesamtes Audit</a>
</form>
</div>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Status</th><th class="right">Aktionen</th></tr>
</thead>
<tbody>
{{range .Reports}}
<tr>
<td class="muted">#{{.ID}}</td>
<td>{{.Name}}</td>
<td>
{{if eq .Status "Gemeldet"}}
<span class="status gemeldet">Gemeldet</span>
{{else}}
<span class="status bestaetigt">Bestätigt</span>
{{end}}
<div class="muted">von {{.ReportedBy}} am {{.Created}}</div>
{{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"}}
{{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</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}}" />
<button type="submit" class="danger">Löschen</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Keine Einträge</td></tr>
{{end}}
</tbody>
</table>
</div>
{{end}}`
var publicTpl = `{{define "public_page"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Advocacy Watchlist - Public</title>
<style>
: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:880px;margin:40px auto;padding:0 16px}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
.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}
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
input[type=text]{flex:1 1 260px;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}
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}
.muted{color:#9ca3af}
</style>
</head>
<body>
<div class="container">
<header>
<div class="brand">Advocacy Watchlist</div>
<div class="flex">
<a class="badge" href="/">🔒 Login</a>
</div>
</header>
<div class="card">
<h2 style="margin:0 0 10px">Public list of confirmed entries</h2>
<form class="row" method="GET" action="/public">
<input name="q" placeholder="Search…" value="{{.Q}}" />
<button type="submit">Filter</button>
</form>
<div class="muted" style="margin-top:8px">Only <b>confirmed</b> entries are displayed.</div>
</div>
<div class="card">
<table>
<thead><tr><th>Benutzername</th><th>Confirmed on</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.Name}}</td>
<td class="muted">{{.Since}}</td>
</tr>
{{else}}
<tr><td colspan="2" class="muted">No matching entries</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>
{{end}}`
var auditTpl = `{{define "audit_page"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<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; }
*{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}
.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:#06b6d4;color:#001018;font-weight:700;cursor:pointer}
.secondary{background:#1f2937;color:#e5e7eb}
</style>
</head>
<body>
<div class="container">
<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>
<thead><tr><th>Zeit</th><th>Nutzer</th><th>Aktion</th><th>Details</th><th>IP</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td class="muted">{{.When}}</td>
<td>{{.Actor}}</td>
<td>{{.Action}}</td>
<td class="muted">{{.Details}}</td>
<td class="muted">{{.IP}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Noch keine Ereignisse</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>
{{end}}`
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>
{{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))
template.Must(tpl.New("users_bundle").Parse(usersTpl))
template.Must(tpl.New("me_bundle").Parse(meTpl))
template.Must(tpl.New("public_bundle").Parse(publicTpl))
http.HandleFunc("/", withAuth(handleIndex))
http.HandleFunc("/public", handlePublic)
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 {
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...)
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]
}
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"))
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
}
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)
}
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)
}
func handlePublic(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
ql := strings.ToLower(q)
mu.Lock()
reports := make([]Report, 0, len(db.Reports))
for _, rep := range db.Reports {
if rep.Status != StatusBestaetigt {
continue // nur bestätigte öffentlich anzeigen
}
if q == "" || strings.Contains(strings.ToLower(rep.Name), ql) {
reports = append(reports, rep)
}
}
mu.Unlock()
rows := make([]map[string]any, 0, len(reports))
for _, rep := range reports {
rows = append(rows, map[string]any{
"Name": template.HTMLEscapeString(rep.Name),
"Since": time.Unix(rep.ConfirmedAt, 0).Format("02.01.2006 15:04"),
})
}
_ = tpl.ExecuteTemplate(w, "public_page", map[string]any{
"Q": q,
"Rows": rows,
})
}
func handleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
reasonText := strings.TrimSpace(r.FormValue("reason"))
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(),
}
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()
appendAudit(user, "report", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r)
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)
return
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
user := currentUser(r)
var did bool
var name string
mu.Lock()
for i, rep := range db.Reports {
if rep.ID == id {
if rep.ReportedBy == user {
mu.Unlock()
appendAudit(user, "confirm_denied", fmt.Sprintf("ID %d · eigener Eintrag", id), r)
http.Redirect(w, r, "/", http.StatusFound)
return
}
if rep.Status == StatusBestaetigt {
break
}
rep.Status = StatusBestaetigt
rep.ConfirmedBy = user
rep.ConfirmedAt = time.Now().Unix()
name = rep.Name
db.Reports[i] = rep
_ = saveDB()
did = true
break
}
}
mu.Unlock()
if did {
appendAudit(user, "confirm", fmt.Sprintf("ID %d · %s", id, name), r)
}
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)
var name string
var ok bool
mu.Lock()
for i, rep := range db.Reports {
if rep.ID == id {
name = rep.Name
db.Reports = append(db.Reports[:i], db.Reports[i+1:]...)
_ = saveDB()
ok = true
break
}
}
mu.Unlock()
if ok {
appendAudit(user, "delete", fmt.Sprintf("ID %d · %s", id, name), r)
}
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 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()
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")})
}
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
}
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 =======
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)
}