Add structured logging and better error handling
This commit is contained in:
1 parent
db4e3c9611
commit
68c7dd76f2
31 files changed
+640
-109
No files matched your search
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,17 +22,18 @@ type sessionResponse struct {
|
||||
func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) {
|
||||
client := server.clientAddress(request)
|
||||
if !server.limiter.Allowed(client) {
|
||||
slog.Warn("login blocked after too many failures", "client", client, "request_id", requestIDFrom(request))
|
||||
writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
var body loginRequest
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) {
|
||||
server.limiter.RecordFailure(client)
|
||||
slog.Warn("login failed", "client", client, "username", body.Username, "request_id", requestIDFrom(request))
|
||||
writeError(writer, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
@@ -38,9 +41,10 @@ func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Requ
|
||||
server.limiter.Reset(client)
|
||||
token, err := server.sessions.Create()
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, "could not create session")
|
||||
writeFailure(writer, request, fmt.Errorf("could not create session: %w", err))
|
||||
return
|
||||
}
|
||||
slog.Info("login succeeded", "client", client, "request_id", requestIDFrom(request))
|
||||
http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds())))
|
||||
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ package server
|
||||
|
||||
import "net/http"
|
||||
|
||||
type reorderRequest struct {
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
func (server *Server) handlePause(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := server.manager.Pause(request.PathValue("id")); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||
@@ -12,24 +16,19 @@ func (server *Server) handlePause(writer http.ResponseWriter, request *http.Requ
|
||||
|
||||
func (server *Server) handleResume(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := server.manager.Resume(request.PathValue("id")); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||
}
|
||||
|
||||
type reorderRequest struct {
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
func (server *Server) handleReorder(writer http.ResponseWriter, request *http.Request) {
|
||||
var body reorderRequest
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if err := server.manager.Reorder(request.PathValue("id"), body.Direction); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, startedResponse{Started: true})
|
||||
@@ -39,9 +38,9 @@ func (server *Server) handleHistory(writer http.ResponseWriter, _ *http.Request)
|
||||
writeJSON(writer, http.StatusOK, server.manager.History())
|
||||
}
|
||||
|
||||
func (server *Server) handleClearHistory(writer http.ResponseWriter, _ *http.Request) {
|
||||
func (server *Server) handleClearHistory(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := server.manager.ClearHistory(); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -23,10 +23,10 @@ func (server *Server) handleLibraries(writer http.ResponseWriter, _ *http.Reques
|
||||
writeJSON(writer, http.StatusOK, server.manager.Destinations())
|
||||
}
|
||||
|
||||
func (server *Server) handleDisk(writer http.ResponseWriter, _ *http.Request) {
|
||||
func (server *Server) handleDisk(writer http.ResponseWriter, request *http.Request) {
|
||||
available, err := server.manager.AvailableBytes()
|
||||
if err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()})
|
||||
@@ -38,13 +38,12 @@ func (server *Server) handleListJobs(writer http.ResponseWriter, _ *http.Request
|
||||
|
||||
func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) {
|
||||
var body jobs.CreateRequest
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
snapshot, err := server.manager.Create(body)
|
||||
if err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusCreated, snapshot)
|
||||
@@ -53,7 +52,7 @@ func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.
|
||||
func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) {
|
||||
tree, err := server.manager.Files(request.PathValue("id"))
|
||||
if err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, tree)
|
||||
@@ -61,12 +60,11 @@ func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.R
|
||||
|
||||
func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) {
|
||||
var body extractRequest
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||
@@ -74,12 +72,11 @@ func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Re
|
||||
|
||||
func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) {
|
||||
var body jobs.MoveRequest
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if err := server.manager.Move(request.PathValue("id"), body); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||
@@ -87,7 +84,7 @@ func (server *Server) handleMove(writer http.ResponseWriter, request *http.Reque
|
||||
|
||||
func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := server.manager.Delete(request.PathValue("id")); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const requestIDKey contextKey = iota
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(status int) {
|
||||
if recorder.status == 0 {
|
||||
recorder.status = status
|
||||
}
|
||||
recorder.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) Write(data []byte) (int, error) {
|
||||
if recorder.status == 0 {
|
||||
recorder.status = http.StatusOK
|
||||
}
|
||||
return recorder.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return recorder.ResponseWriter
|
||||
}
|
||||
|
||||
func (server *Server) observe(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
started := time.Now()
|
||||
requestID := newRequestID()
|
||||
writer.Header().Set("X-Request-ID", requestID)
|
||||
request = request.WithContext(context.WithValue(request.Context(), requestIDKey, requestID))
|
||||
recorder := &statusRecorder{ResponseWriter: writer}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
if recovered == http.ErrAbortHandler {
|
||||
panic(recovered)
|
||||
}
|
||||
slog.Error("request handler crashed",
|
||||
"request_id", requestID,
|
||||
"method", request.Method,
|
||||
"path", request.URL.Path,
|
||||
"panic", recovered,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
if recorder.status == 0 {
|
||||
writeError(recorder, http.StatusInternalServerError, internalErrorMessage(requestID))
|
||||
}
|
||||
}
|
||||
server.logRequest(request, recorder.status, time.Since(started))
|
||||
}()
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) logRequest(request *http.Request, status int, duration time.Duration) {
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
slog.Log(request.Context(), requestLogLevel(request.Method, status), "request",
|
||||
"request_id", requestIDFrom(request),
|
||||
"method", request.Method,
|
||||
"path", request.URL.Path,
|
||||
"status", status,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"client", server.clientAddress(request),
|
||||
)
|
||||
}
|
||||
|
||||
func requestLogLevel(method string, status int) slog.Level {
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
return slog.LevelError
|
||||
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
|
||||
return slog.LevelWarn
|
||||
case method != http.MethodGet && method != http.MethodHead:
|
||||
return slog.LevelInfo
|
||||
default:
|
||||
return slog.LevelDebug
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDFrom(request *http.Request) string {
|
||||
if requestID, ok := request.Context().Value(requestIDKey).(string); ok {
|
||||
return requestID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
buffer := make([]byte, 6)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return hex.EncodeToString(buffer)
|
||||
}
|
||||
|
||||
func internalErrorMessage(requestID string) string {
|
||||
return "Something went wrong on the server (reference " + requestID + "). The server log has the details."
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"ddc/internal/diskspace"
|
||||
@@ -11,24 +12,55 @@ import (
|
||||
"ddc/internal/preferences"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
json.NewEncoder(writer).Encode(payload)
|
||||
if err := json.NewEncoder(writer).Encode(payload); err != nil {
|
||||
slog.Debug("could not write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, message string) {
|
||||
writeJSON(writer, status, map[string]string{"error": message})
|
||||
writeJSON(writer, status, errorResponse{Error: message, RequestID: writer.Header().Get("X-Request-ID")})
|
||||
}
|
||||
|
||||
func writeFailure(writer http.ResponseWriter, err error) {
|
||||
writeError(writer, statusFor(err), err.Error())
|
||||
func writeFailure(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
status := statusFor(err)
|
||||
if status != http.StatusInternalServerError {
|
||||
writeError(writer, status, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
requestID := requestIDFrom(request)
|
||||
slog.Error("request failed",
|
||||
"request_id", requestID,
|
||||
"method", request.Method,
|
||||
"path", request.URL.Path,
|
||||
"error", err,
|
||||
)
|
||||
writeError(writer, status, internalErrorMessage(requestID))
|
||||
}
|
||||
|
||||
func decodeJSON(request *http.Request, target any) error {
|
||||
func decodeBody(writer http.ResponseWriter, request *http.Request, target any) bool {
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(target)
|
||||
err := decoder.Decode(target)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeError(writer, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||
return false
|
||||
}
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
func statusFor(err error) int {
|
||||
|
||||
@@ -59,7 +59,7 @@ func (server *Server) routes() http.Handler {
|
||||
root.Handle("/api/", server.requireSession(api))
|
||||
root.Handle("/", staticHandler())
|
||||
|
||||
return securityHeaders(protectStateChanges(root))
|
||||
return server.observe(securityHeaders(protectStateChanges(root)))
|
||||
}
|
||||
|
||||
func staticHandler() http.Handler {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"ddc/internal/preferences"
|
||||
@@ -18,14 +19,14 @@ func (server *Server) handleGetSettings(writer http.ResponseWriter, _ *http.Requ
|
||||
|
||||
func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) {
|
||||
var body preferences.Preferences
|
||||
if err := decodeJSON(request, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
||||
if !decodeBody(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if err := server.manager.UpdatePreferences(body); err != nil {
|
||||
writeFailure(writer, err)
|
||||
writeFailure(writer, request, err)
|
||||
return
|
||||
}
|
||||
slog.Info("settings updated", "request_id", requestIDFrom(request))
|
||||
writeJSON(writer, http.StatusOK, server.settingsSnapshot())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user