package app import ( "context" "database/sql" "embed" "encoding/json" "errors" "fmt" "io" "io/fs" "log/slog" "mime" "net" "net/http" "net/url" "path" "strconv" "strings" "sync" "time" ) //go:embed web/* var webFS embed.FS type Config struct { DBPath string CookieSecure bool SessionTTL time.Duration } type App struct { cfg Config store *store log *slog.Logger mux *http.ServeMux limiter *loginLimiter static fs.FS } func New(cfg Config, logger *slog.Logger) (*App, error) { if cfg.SessionTTL <= 0 { cfg.SessionTTL = 30 * 24 * time.Hour } st, err := openStore(cfg.DBPath) if err != nil { return nil, err } static, err := fs.Sub(webFS, "web") if err != nil { st.db.Close() return nil, err } a := &App{cfg: cfg, store: st, log: logger, mux: http.NewServeMux(), limiter: newLoginLimiter(), static: static} a.routes() return a, nil } func (a *App) Close() error { return a.store.db.Close() } func (a *App) Handler() http.Handler { return a.securityHeaders(a.recoverer(a.accessLog(a.mux))) } func (a *App) routes() { // Public. a.mux.HandleFunc("GET /healthz", a.health) a.mux.HandleFunc("GET /login", a.loginPage) a.mux.HandleFunc("GET /api/bootstrap", a.bootstrap) a.mux.HandleFunc("POST /api/setup", a.setup) a.mux.HandleFunc("POST /api/login", a.login) fileServer := http.FileServer(http.FS(a.static)) a.mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) // Authenticated HTML. a.mux.Handle("GET /{$}", a.withSession(http.HandlerFunc(a.indexPage))) // Authenticated API. api := http.NewServeMux() api.HandleFunc("GET /api/me", a.me) api.HandleFunc("POST /api/logout", a.logout) api.HandleFunc("POST /api/account/password", a.changeOwnPassword) api.HandleFunc("GET /api/settings", a.getSettings) api.HandleFunc("PUT /api/settings", a.putSettings) api.HandleFunc("GET /api/running", a.getRunning) api.HandleFunc("GET /api/clients", a.getClients) api.HandleFunc("GET /api/entries", a.getEntries) api.HandleFunc("POST /api/entries", a.createEntry) api.HandleFunc("POST /api/entries/start", a.startEntry) api.HandleFunc("POST /api/entries/{id}/stop", a.stopEntry) api.HandleFunc("PUT /api/entries/{id}", a.updateEntry) api.HandleFunc("DELETE /api/entries/{id}", a.deleteEntry) api.HandleFunc("GET /api/export.csv", a.exportCSV) api.HandleFunc("GET /api/export.pdf", a.exportPDF) api.HandleFunc("POST /api/service-report.pdf", a.serviceReportPDF) api.HandleFunc("GET /api/admin/users", a.adminUsers) api.HandleFunc("POST /api/admin/users", a.adminCreateUser) api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser) api.HandleFunc("POST /api/admin/users/{id}/password", a.adminResetPassword) a.mux.Handle("/api/", a.withSession(api)) } func (a *App) health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := a.store.db.PingContext(ctx); err != nil { jsonError(w, 503, "db_unavailable", "database unavailable") return } writeJSON(w, 200, map[string]string{"status": "ok"}) } func (a *App) loginPage(w http.ResponseWriter, r *http.Request) { if _, err := a.sessionFromRequest(r); err == nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } a.serveAsset(w, r, "login.html") } func (a *App) indexPage(w http.ResponseWriter, r *http.Request) { a.serveAsset(w, r, "index.html") } func (a *App) serveAsset(w http.ResponseWriter, r *http.Request, name string) { b, err := fs.ReadFile(a.static, name) if err != nil { http.NotFound(w, r) return } if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { w.Header().Set("Content-Type", ct) } if strings.HasSuffix(name, ".html") { w.Header().Set("Cache-Control", "no-store") } _, _ = w.Write(b) } func (a *App) bootstrap(w http.ResponseWriter, r *http.Request) { needs, err := a.store.needsSetup(r.Context()) if err != nil { jsonError(w, 500, "db", "Datenbankfehler.") return } writeJSON(w, 200, map[string]bool{"needs_setup": needs}) } func (a *App) setup(w http.ResponseWriter, r *http.Request) { needs, err := a.store.needsSetup(r.Context()) if err != nil { jsonError(w, 500, "db", "Datenbankfehler.") return } if !needs { jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.") return } var in struct { Username string `json:"username"` DisplayName string `json:"displayName"` Password string `json:"password"` } if !decodeJSON(w, r, &in) { return } if !validUsername(in.Username) { jsonError(w, 400, "username", "Benutzername: 3–64 Zeichen, Buchstaben/Zahlen/._-.") return } hash, err := hashPassword(in.Password) if err != nil { jsonError(w, 400, "password", err.Error()) return } u, err := a.store.bootstrapAdmin(r.Context(), in.Username, in.DisplayName, hash) if errors.Is(err, errAlreadySetup) { jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.") return } if err != nil { jsonError(w, 409, "create_user", "Benutzer konnte nicht angelegt werden.") return } token, _, exp, err := a.createSession(r.Context(), u.ID) if err != nil { jsonError(w, 500, "session", "Session konnte nicht erstellt werden.") return } a.setSessionCookie(w, token, exp) writeJSON(w, 201, map[string]any{"user": u}) } func (a *App) login(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if !a.limiter.allow(ip) { jsonError(w, 429, "rate_limited", "Zu viele Anmeldeversuche. Bitte später erneut versuchen.") return } var in struct { Username string `json:"username"` Password string `json:"password"` } if !decodeJSON(w, r, &in) { return } u, hash, err := a.store.userForLogin(r.Context(), in.Username) hashToCheck := hash if err != nil { hashToCheck = dummyPasswordHash } passwordOK := verifyPassword(hashToCheck, in.Password) if err != nil || !u.Active || !passwordOK { a.limiter.fail(ip) time.Sleep(150 * time.Millisecond) jsonError(w, 401, "bad_credentials", "Benutzername oder Passwort ist falsch.") return } a.limiter.success(ip) _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE expires_at_ms<=?`, time.Now().UnixMilli()) token, _, exp, err := a.createSession(r.Context(), u.ID) if err != nil { jsonError(w, 500, "session", "Session konnte nicht erstellt werden.") return } a.setSessionCookie(w, token, exp) writeJSON(w, 200, map[string]any{"user": u}) } func (a *App) me(w http.ResponseWriter, r *http.Request) { s := sessionOf(r) writeJSON(w, 200, map[string]any{"user": s.User, "csrf_token": s.CSRF}) } func (a *App) logout(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } a.deleteCurrentSession(w, r) w.WriteHeader(204) } func (a *App) changeOwnPassword(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { Current string `json:"current_password"` New string `json:"new_password"` } if !decodeJSON(w, r, &in) { return } s := sessionOf(r) _, currentHash, err := a.store.userForLogin(r.Context(), s.User.Username) if err != nil || !verifyPassword(currentHash, in.Current) { jsonError(w, http.StatusUnauthorized, "bad_password", "Das aktuelle Passwort ist falsch.") return } hash, err := hashPassword(in.New) if err != nil { jsonError(w, 400, "password", err.Error()) return } if err := a.store.resetPassword(r.Context(), s.User.ID, hash); err != nil { jsonError(w, 500, "db", "Passwort konnte nicht geändert werden.") return } a.clearSessionCookie(w) w.WriteHeader(http.StatusNoContent) } func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { x, err := a.store.settings(r.Context(), sessionOf(r).User.ID) if err != nil { jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") return } writeJSON(w, 200, x) } func (a *App) putSettings(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var x Settings if !decodeJSON(w, r, &x) { return } if x.Language != "de" && x.Language != "en" { x.Language = "de" } if x.TimeFormat != "12" { x.TimeFormat = "24" } if x.RoundingMinutes < 1 || x.RoundingMinutes > 60 { jsonError(w, 400, "rounding", "Rundung muss zwischen 1 und 60 Minuten liegen.") return } if len(x.ExportName) > 120 { jsonError(w, 400, "export_name", "Name ist zu lang.") return } if len(x.Timezone) > 80 { jsonError(w, 400, "timezone", "Zeitzone ist ungültig.") return } if _, err := time.LoadLocation(x.Timezone); err != nil { x.Timezone = "UTC" } if err := a.store.updateSettings(r.Context(), sessionOf(r).User.ID, x); err != nil { jsonError(w, 500, "db", "Einstellungen konnten nicht gespeichert werden.") return } writeJSON(w, 200, x) } func (a *App) getRunning(w http.ResponseWriter, r *http.Request) { e, err := a.store.runningEntry(r.Context(), sessionOf(r).User.ID) if err != nil { jsonError(w, 500, "db", "Timer konnte nicht geladen werden.") return } writeJSON(w, 200, map[string]any{"entry": e}) } func (a *App) getClients(w http.ResponseWriter, r *http.Request) { x, err := a.store.recentClients(r.Context(), sessionOf(r).User.ID) if err != nil { jsonError(w, 500, "db", "Kunden konnten nicht geladen werden.") return } writeJSON(w, 200, map[string]any{"clients": x}) } func (a *App) getEntries(w http.ResponseWriter, r *http.Request) { f, ok := parseFilter(w, r) if !ok { return } cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID) if err != nil { jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") return } p, err := a.store.listEntries(r.Context(), sessionOf(r).User.ID, f, cfg) if err != nil { jsonError(w, 500, "db", "Einträge konnten nicht geladen werden.") return } writeJSON(w, 200, p) } func (a *App) startEntry(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { Client string `json:"client"` Activity string `json:"activity"` StartMS int64 `json:"start_ms"` } if !decodeJSON(w, r, &in) { return } if len(in.Client) > 200 || len(in.Activity) > 4000 { jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") return } if in.StartMS < 0 { jsonError(w, 400, "start_ms", "Ungültiger Startzeitpunkt.") return } e, err := a.store.startEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS) if err != nil { if isUniqueConstraint(err) { jsonError(w, 409, "timer_running", "Es läuft bereits ein Timer.") return } jsonError(w, 500, "db", "Timer konnte nicht gestartet werden.") return } writeJSON(w, 201, e) } func (a *App) createEntry(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { Client string `json:"client"` Activity string `json:"activity"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` } if !decodeJSON(w, r, &in) { return } if len(in.Client) > 200 || len(in.Activity) > 4000 { jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") return } e, err := a.store.createFinishedEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS, in.EndMS) if err != nil { jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.") return } writeJSON(w, 201, e) } func (a *App) stopEntry(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { EndMS int64 `json:"end_ms"` } if !decodeJSONAllowEmpty(w, r, &in) { return } e, err := a.store.stopEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.EndMS) if err != nil { if errors.Is(err, sql.ErrNoRows) { jsonError(w, 404, "not_found", "Laufender Eintrag nicht gefunden.") return } jsonError(w, 500, "db", "Timer konnte nicht gestoppt werden.") return } writeJSON(w, 200, e) } func (a *App) updateEntry(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { Client string `json:"client"` Activity string `json:"activity"` StartMS int64 `json:"start_ms"` EndMS *int64 `json:"end_ms"` } if !decodeJSON(w, r, &in) { return } if len(in.Client) > 200 || len(in.Activity) > 4000 { jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") return } e, err := a.store.updateEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.Client, in.Activity, in.StartMS, in.EndMS) if err != nil { if errors.Is(err, sql.ErrNoRows) { jsonError(w, 404, "not_found", "Eintrag nicht gefunden.") return } if isUniqueConstraint(err) { jsonError(w, 409, "timer_running", "Es kann nur einen laufenden Timer geben.") return } jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.") return } writeJSON(w, 200, e) } func (a *App) deleteEntry(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } if err := a.store.deleteEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id")); err != nil { if errors.Is(err, sql.ErrNoRows) { jsonError(w, 404, "not_found", "Eintrag nicht gefunden.") return } jsonError(w, 500, "db", "Eintrag konnte nicht gelöscht werden.") return } w.WriteHeader(204) } func (a *App) exportCSV(w http.ResponseWriter, r *http.Request) { f, ok := parseFilter(w, r) if !ok { return } entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f) if err != nil { jsonError(w, 500, "db", "Export konnte nicht erstellt werden.") return } cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID) b, err := makeCSV(entries, cfg, f.Compact) if err != nil { jsonError(w, 500, "export", "CSV konnte nicht erstellt werden.") return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.csv"`) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(b) } func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) { f, ok := parseFilter(w, r) if !ok { return } entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f) if err != nil { jsonError(w, 500, "db", "Export konnte nicht erstellt werden.") return } cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID) b := makePDF(entries, cfg, sessionOf(r).User, f.Compact) w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.pdf"`) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(b) } func (a *App) serviceReportPDF(w http.ResponseWriter, r *http.Request) { if !requireCSRF(w, r) { return } var in struct { Client string `json:"client"` Contact string `json:"contact"` Location string `json:"location"` OrderNumber string `json:"order_number"` Subject string `json:"subject"` Notes string `json:"notes"` Place string `json:"place"` ReportDate string `json:"report_date"` Query string `json:"q"` FromMS int64 `json:"from_ms"` ToMS int64 `json:"to_ms"` } if !decodeJSON(w, r, &in) { return } in.Client = strings.TrimSpace(in.Client) if in.Client == "" { jsonError(w, 400, "client", "Bitte einen Kunden für den Service-Bericht auswählen.") return } if len(in.Client) > 200 || len(in.Contact) > 200 || len(in.Location) > 400 || len(in.OrderNumber) > 120 || len(in.Subject) > 200 || len(in.Notes) > 6000 || len(in.Place) > 120 || len(in.Query) > 200 { jsonError(w, 400, "too_long", "Ein Feld im Service-Bericht ist zu lang.") return } if in.FromMS < 0 || in.ToMS < 0 || (in.FromMS > 0 && in.ToMS > 0 && in.ToMS <= in.FromMS) { jsonError(w, 400, "period", "Ungültiger Berichtszeitraum.") return } reportDate := "" if strings.TrimSpace(in.ReportDate) != "" { d, err := time.Parse("2006-01-02", in.ReportDate) if err != nil { jsonError(w, 400, "report_date", "Ungültiges Berichtsdatum.") return } reportDate = d.Format("02.01.2006") } entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, entryFilter{ Query: strings.TrimSpace(in.Query), Client: in.Client, FromMS: in.FromMS, ToMS: in.ToMS, SortAsc: true, }) if err != nil { jsonError(w, 500, "db", "Service-Bericht konnte nicht erstellt werden.") return } if len(entries) == 0 { jsonError(w, 400, "empty", "Für diesen Kunden und Zeitraum wurden keine abgeschlossenen Tätigkeiten gefunden.") return } cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID) if err != nil { jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") return } pdf := makeServiceReportPDF(entries, cfg, sessionOf(r).User, serviceReportMeta{ Client: in.Client, Contact: strings.TrimSpace(in.Contact), Location: strings.TrimSpace(in.Location), OrderNumber: strings.TrimSpace(in.OrderNumber), Subject: strings.TrimSpace(in.Subject), Notes: strings.TrimSpace(in.Notes), Place: strings.TrimSpace(in.Place), ReportDate: reportDate, }) w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", `attachment; filename="service-bericht.pdf"`) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(pdf) } func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return } users, err := a.store.listUsers(r.Context()) if err != nil { jsonError(w, 500, "db", "Benutzer konnten nicht geladen werden.") return } writeJSON(w, 200, map[string]any{"users": users}) } func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) || !requireCSRF(w, r) { return } var in struct { Username string `json:"username"` DisplayName string `json:"displayName"` Password string `json:"password"` Role string `json:"role"` } if !decodeJSON(w, r, &in) { return } if !validUsername(in.Username) { jsonError(w, 400, "username", "Ungültiger Benutzername.") return } hash, err := hashPassword(in.Password) if err != nil { jsonError(w, 400, "password", err.Error()) return } u, err := a.store.createUser(r.Context(), in.Username, in.DisplayName, hash, in.Role) if err != nil { jsonError(w, 409, "username_exists", "Benutzername ist bereits vergeben.") return } writeJSON(w, 201, u) } func (a *App) adminPatchUser(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) || !requireCSRF(w, r) { return } id := r.PathValue("id") if id == sessionOf(r).User.ID { jsonError(w, 400, "self", "Den eigenen Account hier nicht deaktivieren.") return } var in struct { Active *bool `json:"active"` } if !decodeJSON(w, r, &in) { return } if in.Active == nil { jsonError(w, 400, "active", "active fehlt.") return } if err := a.store.setUserActive(r.Context(), id, *in.Active); err != nil { jsonError(w, 404, "not_found", "Benutzer nicht gefunden.") return } w.WriteHeader(204) } func (a *App) adminResetPassword(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) || !requireCSRF(w, r) { return } var in struct { Password string `json:"password"` } if !decodeJSON(w, r, &in) { return } hash, err := hashPassword(in.Password) if err != nil { jsonError(w, 400, "password", err.Error()) return } if err := a.store.resetPassword(r.Context(), r.PathValue("id"), hash); err != nil { jsonError(w, 404, "not_found", "Benutzer nicht gefunden.") return } w.WriteHeader(204) } func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) { q := r.URL.Query() from, ok := parseInt64Param(w, q, "from") if !ok { return entryFilter{}, false } to, ok := parseInt64Param(w, q, "to") if !ok { return entryFilter{}, false } limit := 200 if x := q.Get("limit"); x != "" { n, err := strconv.Atoi(x) if err != nil || n < 1 { jsonError(w, 400, "limit", "Ungültiges Limit.") return entryFilter{}, false } if n > 500 { n = 500 } limit = n } offset := 0 if x := q.Get("offset"); x != "" { n, err := strconv.Atoi(x) if err != nil || n < 0 { jsonError(w, 400, "offset", "Ungültiger Offset.") return entryFilter{}, false } offset = n } client := strings.TrimSpace(q.Get("client")) if len(client) > 200 { jsonError(w, 400, "client", "Kunde ist zu lang.") return entryFilter{}, false } return entryFilter{Query: q.Get("q"), Client: client, FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true } func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) { x := q.Get(key) if x == "" { return 0, true } n, err := strconv.ParseInt(x, 10, 64) if err != nil || n < 0 { jsonError(w, 400, key, "Ungültiger Zeitraum.") return 0, false } return n, true } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") { jsonError(w, 415, "content_type", "Content-Type application/json erforderlich.") return false } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { jsonError(w, 400, "json", "Ungültige JSON-Daten.") return false } if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { jsonError(w, 400, "json", "Nach dem JSON-Objekt sind weitere Daten enthalten.") return false } return true } func decodeJSONAllowEmpty(w http.ResponseWriter, r *http.Request, dst any) bool { if r.ContentLength == 0 { return true } return decodeJSON(w, r, dst) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func jsonError(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}}) } func validUsername(s string) bool { s = strings.TrimSpace(s) if len(s) < 3 || len(s) > 64 { return false } for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { continue } return false } return true } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return host } return r.RemoteAddr } func (a *App) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") next.ServeHTTP(w, r) }) } func (a *App) recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if v := recover(); v != nil { a.log.Error("panic", "value", fmt.Sprint(v), "path", r.URL.Path) jsonError(w, 500, "internal", "Interner Serverfehler.") } }() next.ServeHTTP(w, r) }) } func (a *App) accessLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) if r.URL.Path != "/healthz" { a.log.Info("http", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds()) } }) } type loginAttempt struct { fails int first, blockedUntil time.Time } type loginLimiter struct { mu sync.Mutex m map[string]loginAttempt } func newLoginLimiter() *loginLimiter { return &loginLimiter{m: map[string]loginAttempt{}} } func (l *loginLimiter) allow(k string) bool { l.mu.Lock() defer l.mu.Unlock() x := l.m[k] return x.blockedUntil.IsZero() || time.Now().After(x.blockedUntil) } func (l *loginLimiter) fail(k string) { l.mu.Lock() defer l.mu.Unlock() now := time.Now() x := l.m[k] if x.first.IsZero() || now.Sub(x.first) > 10*time.Minute { x = loginAttempt{first: now} } x.fails++ if x.fails >= 5 { x.blockedUntil = now.Add(10 * time.Minute) } l.m[k] = x } func (l *loginLimiter) success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.m, k) }