// main.go
// Go 1.21+
// Einfache Webanwendung mit Login, Persistenz (JSON-Datei), Audit-Log,
// und UI ohne externe Verweise. Nutzer können "Benutzernamen" melden,
// Einträge starten mit Status "Gemeldet" und können von einem anderen
// Nutzer bestätigt werden. Alles wird audit-geeignet protokolliert.
//
// Hinweise: Für Produktion TLS, CSRF, bcrypt/argon2id etc. ergänzen.
package main
import (
"context"
"crypto/hmac"
crand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"log"
mrand "math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// ======= Datenmodelle =======
type User struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt int64 `json:"created_at"`
}
type Status string
const (
StatusGemeldet Status = "Gemeldet"
StatusBestaetigt Status = "Bestätigt"
)
type Report struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status Status `json:"status"`
ReportedBy string `json:"reported_by"`
ConfirmedBy string `json:"confirmed_by"`
CreatedAt int64 `json:"created_at"`
ConfirmedAt int64 `json:"confirmed_at"`
}
type AuditEntry struct {
Time int64 `json:"time"`
Actor string `json:"actor"`
Action string `json:"action"`
Details string `json:"details"`
IP string `json:"ip"`
}
type DB struct {
Users []User `json:"users"`
Reports []Report `json:"reports"`
Audit []AuditEntry `json:"audit"`
NextID int64 `json:"next_id"`
}
// ======= Globale Variablen =======
var (
dataPath = envOr("DATA_FILE", "data.json")
cookieName = envOr("COOKIE_NAME", "session")
secretKey []byte // für HMAC der Session-Cookies
mu sync.Mutex
db DB
tpl *template.Template
)
// ======= Hilfsfunktionen =======
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func loadDB() error {
mu.Lock()
defer mu.Unlock()
f, err := os.Open(dataPath)
if err != nil {
if os.IsNotExist(err) {
db = DB{NextID: 1}
return saveDB()
}
return err
}
defer f.Close()
decoder := json.NewDecoder(f)
return decoder.Decode(&db)
}
func saveDB() error {
f, err := os.CreateTemp(filepath.Dir(dataPath), "dbtmp-*.json")
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(db); err != nil {
f.Close()
os.Remove(f.Name())
return err
}
f.Close()
return os.Rename(f.Name(), dataPath)
}
// ======= Pseudo-PBKDF (nur Demo!) =======
// Für Produktion: bcrypt/argon2id verwenden.
func hashPassword(pw string, salt []byte) string {
h := sha256.New()
h.Write(salt)
h.Write([]byte(pw))
for i := 0; i < 100000; i++ { // 100k Runden
h.Write([]byte{byte(i)})
}
return base64.StdEncoding.EncodeToString(h.Sum(nil)) + ":" + base64.StdEncoding.EncodeToString(salt)
}
func verifyPassword(pw, stored string) bool {
parts := strings.Split(stored, ":")
if len(parts) != 2 {
return false
}
salt, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
return hashPassword(pw, salt) == stored
}
func newSalt() []byte { b := make([]byte, 16); crand.Read(b); return b }
// ======= Sessions (signiertes Cookie) =======
type Session struct {
Username string
Expires int64
}
func sign(data []byte) string {
mac := hmac.New(sha256.New, secretKey)
mac.Write(data)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func setSession(w http.ResponseWriter, username string) {
s := fmt.Sprintf("%s|%d", username, time.Now().Add(24*time.Hour).Unix())
sig := sign([]byte(s))
value := base64.StdEncoding.EncodeToString([]byte(s)) + "." + sig
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: value,
Path: "/",
HttpOnly: true,
Secure: false, // In Produktion: true + HTTPS
SameSite: http.SameSiteLaxMode,
})
}
func clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", Expires: time.Unix(0, 0)})
}
func getSession(r *http.Request) (string, bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return "", false
}
parts := strings.SplitN(c.Value, ".", 2)
if len(parts) != 2 {
return "", false
}
payloadB, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
if sign(payloadB) != parts[1] {
return "", false
}
payload := string(payloadB)
sp := strings.Split(payload, "|")
if len(sp) != 2 {
return "", false
}
exp, _ := strconv.ParseInt(sp[1], 10, 64)
if time.Now().Unix() > exp {
return "", false
}
return sp[0], true
}
// ======= Middleware =======
type ctxKey string
var userKey ctxKey = "user"
func withAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := getSession(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userKey, u)
r = r.WithContext(ctx)
h(w, r)
}
}
func currentUser(r *http.Request) string {
if v := r.Context().Value(userKey); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// ======= Template & Assets =======
var baseTpl = `{{define "base"}}
{{block "title" .}}Meldesystem{{end}}
{{template "content" .}}
{{end}}`
var loginTpl = `{{define "login"}}
Login · Meldesystem
Anmeldung
{{if .Msg}}
{{.Msg}}
{{end}}
{{if .Setup}}
Erstbenutzer: {{.SetupUser}} / Passwort: {{.SetupPass}} (bitte nach dem Login ändern)
{{end}}
{{end}}`
var dashboardTpl = `{{define "dashboard"}}{{template "base" .}}{{end}}
{{define "content"}}
Neuen Benutzernamen melden
Einträge starten mit Status Gemeldet und müssen von einem anderen Nutzer bestätigt werden.
Audit-Log
Letzte 8 Ereignisse
| Zeit | Aktion | Details |
{{range .RecentAudit}}
| {{.When}} |
{{.Action}} |
{{.Details}} |
{{else}}
| Noch keine Ereignisse |
{{end}}
| ID | Name | Status | Aktionen |
{{range .Reports}}
| #{{.ID}} |
{{.Name}} |
{{if eq .Status "Gemeldet"}}
Gemeldet
{{else}}
Bestätigt
{{end}}
von {{.ReportedBy}} am {{.Created}}
{{if .Confirmed}}
bestätigt von {{.ConfirmedBy}} am {{.Confirmed}}
{{end}}
|
{{if eq .Status "Gemeldet"}}
{{else}}
✔ Bereits bestätigt
{{end}}
{{if $.IsAdmin}}
{{end}}
|
{{else}}
| Keine Einträge |
{{end}}
{{end}}`
var auditTpl = `{{define "audit_page"}}
Audit · Meldesystem
Audit-Log (vollständig)
| Zeit | Nutzer | Aktion | Details | IP |
{{range .Rows}}
| {{.When}} |
{{.Actor}} |
{{.Action}} |
{{.Details}} |
{{.IP}} |
{{else}}
| Noch keine Ereignisse |
{{end}}
{{end}}`
// ======= Server =======
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
// Geheimschlüssel laden/erzeugen
if k := os.Getenv("APP_SECRET"); k != "" {
secretKey = []byte(k)
} else {
secretKey = make([]byte, 32)
if _, err := crand.Read(secretKey); err != nil {
log.Fatal(err)
}
}
if err := loadDB(); err != nil {
log.Fatal(err)
}
setupUser, setupPass, setup := ensureAdmin()
tpl = template.Must(template.New("base").Parse(baseTpl))
template.Must(tpl.New("dashboard_bundle").Parse(dashboardTpl))
template.Must(tpl.New("audit_bundle").Parse(auditTpl))
http.HandleFunc("/", withAuth(handleIndex))
http.HandleFunc("/report", withAuth(handleReport))
http.HandleFunc("/confirm", withAuth(handleConfirm))
http.HandleFunc("/delete", withAuth(handleDelete))
http.HandleFunc("/audit", withAuth(handleAudit))
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
t := template.Must(template.New("login").Parse(loginTpl))
msg := r.URL.Query().Get("msg")
_ = t.ExecuteTemplate(w, "login", map[string]any{"Msg": msg, "Setup": setup, "SetupUser": setupUser, "SetupPass": setupPass})
return
}
if r.Method == http.MethodPost {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if authenticate(username, password) {
setSession(w, username)
appendAudit(username, "login", "Erfolg", r)
http.Redirect(w, r, "/", http.StatusFound)
return
}
appendAudit(username, "login", "Fehlgeschlagen", r)
http.Redirect(w, r, "/login?msg=Ungültige%20Anmeldedaten", http.StatusFound)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
u := currentUser(r)
clearSession(w)
appendAudit(u, "logout", "", r)
http.Redirect(w, r, "/login?msg=Abgemeldet", http.StatusFound)
})
addr := envOr("ADDR", ":8080")
log.Printf("Server läuft auf %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
// ======= Admin/Users =======
func ensureAdmin() (string, string, bool) {
mu.Lock()
defer mu.Unlock()
if len(db.Users) > 0 {
return "", "", false
}
user := envOr("ADMIN_USER", "admin")
pass := os.Getenv("ADMIN_PASS")
if pass == "" {
pass = randomPassword(12)
}
h := hashPassword(pass, newSalt())
db.Users = append(db.Users, User{Username: user, PasswordHash: h, IsAdmin: true, CreatedAt: time.Now().Unix()})
if err := saveDB(); err != nil {
log.Fatal(err)
}
log.Printf("Erstbenutzer angelegt: %s / %s (bitte ändern)", user, pass)
return user, pass, true
}
func authenticate(username, password string) bool {
mu.Lock()
defer mu.Unlock()
for _, u := range db.Users {
if u.Username == username && verifyPassword(password, u.PasswordHash) {
return true
}
}
return false
}
func isAdmin(username string) bool {
mu.Lock()
defer mu.Unlock()
for _, u := range db.Users {
if u.Username == username {
return u.IsAdmin
}
}
return false
}
// ======= Audit =======
func appendAudit(actor, action, details string, r *http.Request) {
mu.Lock()
defer mu.Unlock()
entry := AuditEntry{Time: time.Now().Unix(), Actor: actor, Action: action, Details: details, IP: clientIP(r)}
db.Audit = append([]AuditEntry{entry}, db.Audit...) // neueste oben
if len(db.Audit) > 5000 {
db.Audit = db.Audit[:5000]
}
_ = saveDB()
}
func clientIP(r *http.Request) string {
if x := r.Header.Get("X-Forwarded-For"); x != "" {
return strings.Split(x, ",")[0]
}
return strings.Split(r.RemoteAddr, ":")[0]
}
// ======= Handlers =======
func handleIndex(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
mu.Lock()
reports := make([]Report, 0, len(db.Reports))
for _, rep := range db.Reports {
if q == "" || strings.Contains(strings.ToLower(rep.Name), strings.ToLower(q)) {
reports = append(reports, rep)
}
}
mu.Unlock()
rows := make([]map[string]any, 0, len(reports))
for _, rep := range reports {
m := map[string]any{
"ID": rep.ID,
"Name": template.HTMLEscapeString(rep.Name),
"Status": rep.Status,
"ReportedBy": rep.ReportedBy,
"Created": time.Unix(rep.CreatedAt, 0).Format("02.01.2006 15:04"),
}
if rep.Status == StatusBestaetigt {
m["Confirmed"] = time.Unix(rep.ConfirmedAt, 0).Format("02.01.2006 15:04")
m["ConfirmedBy"] = rep.ConfirmedBy
}
rows = append(rows, m)
}
recent := recentAudit(8)
data := map[string]any{
"User": currentUser(r),
"IsAdmin": isAdmin(currentUser(r)),
"Reports": rows,
"Q": q,
"RecentAudit": recent,
"Now": time.Now().Format("02.01.2006 15:04"),
}
_ = tpl.ExecuteTemplate(w, "dashboard", data)
}
func handleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Redirect(w, r, "/?msg=leer", http.StatusFound)
return
}
user := currentUser(r)
mu.Lock()
for _, rep := range db.Reports {
if strings.EqualFold(rep.Name, name) {
mu.Unlock()
appendAudit(user, "report_skip", fmt.Sprintf("Duplikat: %s", name), r)
http.Redirect(w, r, "/?msg=exists", http.StatusFound)
return
}
}
id := db.NextID
db.NextID++
rep := Report{ID: id, Name: name, Status: StatusGemeldet, ReportedBy: user, CreatedAt: time.Now().Unix()}
db.Reports = append([]Report{rep}, db.Reports...)
_ = saveDB()
mu.Unlock()
appendAudit(user, "report", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r)
http.Redirect(w, r, "/", http.StatusFound)
}
func handleConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
user := currentUser(r)
mu.Lock()
defer mu.Unlock()
for i, rep := range db.Reports {
if rep.ID == id {
if rep.ReportedBy == user { // nicht selbst bestätigen
appendAudit(user, "confirm_denied", fmt.Sprintf("ID %d · eigener Eintrag", id), r)
break
}
if rep.Status == StatusBestaetigt {
break
}
rep.Status = StatusBestaetigt
rep.ConfirmedBy = user
rep.ConfirmedAt = time.Now().Unix()
db.Reports[i] = rep
_ = saveDB()
appendAudit(user, "confirm", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r)
break
}
}
http.Redirect(w, r, "/", http.StatusFound)
}
func handleDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !isAdmin(currentUser(r)) {
w.WriteHeader(http.StatusForbidden)
return
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
user := currentUser(r)
mu.Lock()
defer mu.Unlock()
for i, rep := range db.Reports {
if rep.ID == id {
db.Reports = append(db.Reports[:i], db.Reports[i+1:]...)
_ = saveDB()
appendAudit(user, "delete", fmt.Sprintf("ID %d · %s", rep.ID, rep.Name), r)
break
}
}
http.Redirect(w, r, "/", http.StatusFound)
}
func handleAudit(w http.ResponseWriter, r *http.Request) {
rows := recentAudit(1000)
data := map[string]any{"User": currentUser(r), "Rows": rows, "Now": time.Now().Format("02.01.2006 15:04"), "IsAdmin": isAdmin(currentUser(r))}
_ = tpl.ExecuteTemplate(w, "audit_page", data)
}
func recentAudit(n int) []map[string]any {
mu.Lock()
defer mu.Unlock()
limit := n
if len(db.Audit) < n {
limit = len(db.Audit)
}
out := make([]map[string]any, 0, limit)
for _, a := range db.Audit[:limit] {
out = append(out, map[string]any{
"When": time.Unix(a.Time, 0).Format("02.01.2006 15:04:05"),
"Actor": a.Actor,
"Action": a.Action,
"Details": a.Details,
"IP": a.IP,
})
}
return out
}
// ======= Utilities =======
func randomPassword(n int) string {
letters := []rune("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#%")
b := make([]rune, n)
s := mrand.New(mrand.NewSource(time.Now().UnixNano()))
for i := range b {
b[i] = letters[s.Intn(len(letters))]
}
return string(b)
}