772 lines
23 KiB
Go
772 lines
23 KiB
Go
// 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"}}
|
|
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<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}
|
|
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}
|
|
.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">Meldesystem</div>
|
|
<div class="flex">
|
|
<span class="badge">👤 {{.User}}</span>
|
|
<form method="POST" action="/logout"><button class="secondary" type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
{{template "content" .}}
|
|
<footer>Ohne externe Assets · 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 · 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(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} .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>
|
|
<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 />
|
|
<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}}
|
|
</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 $.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 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; --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: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}
|
|
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}
|
|
.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}
|
|
</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="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>
|
|
|
|
<footer>Ohne externe Assets · Zeit: {{.Now}}</footer>
|
|
</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))
|
|
|
|
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)
|
|
}
|