// Package api contains HTTP handlers, middleware, and request/response DTOs. package api import ( "crypto/rand" "crypto/subtle" "fmt" "log/slog" "net/http" "strings" "time" "github.com/shcizo/package-updater/internal/logging" ) // Auth returns middleware that requires a matching bearer token. // Compares with constant-time to defeat timing attacks. func Auth(token string) func(http.Handler) http.Handler { tokenBytes := []byte(token) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") const prefix = "Bearer " if !strings.HasPrefix(h, prefix) { writeAuthError(w) return } provided := []byte(strings.TrimPrefix(h, prefix)) if len(provided) == 0 || subtle.ConstantTimeCompare(provided, tokenBytes) != 1 { writeAuthError(w) return } next.ServeHTTP(w, r) }) } } func writeAuthError(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) } // newUUID generates a random UUID v4 string using crypto/rand. func newUUID() string { var b [16]byte _, _ = rand.Read(b[:]) b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant bits return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } // RequestID middleware ensures every request has an X-Request-ID // header (generated if absent) and stores it in the request context. func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-ID") if id == "" { id = newUUID() } w.Header().Set("X-Request-ID", id) ctx := logging.WithRequestID(r.Context(), id) next.ServeHTTP(w, r.WithContext(ctx)) }) } // RequestLogger emits a structured access-log line per request. func RequestLogger(base *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) logger := logging.FromContext(r.Context(), base) logger.Info("http_request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "duration_ms", time.Since(start).Milliseconds(), "client_ip", clientIP(r), ) }) } } type statusWriter struct { http.ResponseWriter status int } func (s *statusWriter) WriteHeader(code int) { s.status = code s.ResponseWriter.WriteHeader(code) } func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if comma := strings.Index(xff, ","); comma >= 0 { return strings.TrimSpace(xff[:comma]) } return xff } return r.RemoteAddr }