All checks were successful
release-tag / release-image (push) Successful in 2m6s
834 lines
24 KiB
Go
834 lines
24 KiB
Go
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) }
|