Files
flancer/internal/app/server.go
jbergner 57f7470310
All checks were successful
release-tag / release-image (push) Successful in 2m6s
Mobile-Update + Bericht
2026-08-14 18:17:38 +02:00

834 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 364 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) }