init
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-14 17:42:54 +02:00
parent 18a48d2285
commit 222b5d2413
22 changed files with 3013 additions and 1 deletions
+757
View File
@@ -0,0 +1,757 @@
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("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) 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
}
return entryFilter{Query: q.Get("q"), 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) }