webapp
Some checks failed
Docker Build and Publish / publish (push) Failing after 1m33s

This commit is contained in:
Eugene Howe
2026-02-17 09:47:30 -05:00
parent af09672ee3
commit b0957bfa49
102 changed files with 4213 additions and 378 deletions

View File

@@ -0,0 +1,15 @@
package middleware
import (
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
func AddRequestIDHeaderMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(middleware.RequestIDHeader, middleware.GetReqID(r.Context()))
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

View File

@@ -0,0 +1,118 @@
package middleware
import (
"context"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
func verifyTokenAndGetClaims(config *config.ApplicationConfig, ctx context.Context, token string) (*oidc.IDToken, Claims, error) {
idToken, err := config.OidcConfig.Verifier.Verify(ctx, token)
claims := Claims{Groups: []string{}}
if err != nil {
return idToken, claims, err
}
err = idToken.Claims(&claims)
if err != nil {
return idToken, claims, err
}
return idToken, claims, nil
}
func serveWithTokenAndClaims(next http.Handler, r *http.Request, w http.ResponseWriter, claims Claims, token *oidc.IDToken) {
ctx := context.WithValue(r.Context(), "user", token)
ctx = context.WithValue(ctx, "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
}
func CurrentUserMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
rawIDToken, ok := session.Values["id_token"].(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// In test environment, bypass OIDC verification and use mock claims
if config.Environment == "test" || config.Environment == "testEnvironment" {
mockClaims := Claims{
Sub: "test-user-id",
Name: "Test User",
Email: "test@example.com",
Groups: []string{"calculate-negative-points-users"},
}
serveWithTokenAndClaims(next, r, w, mockClaims, nil)
return
}
idToken, claims, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
if err == nil {
serveWithTokenAndClaims(next, r, w, claims, idToken)
return
}
refreshToken, ok := session.Values["refresh_token"].(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Attempt refresh
tokenSrc := config.OidcConfig.OAuth2.TokenSource(r.Context(), &oauth2.Token{
RefreshToken: refreshToken,
})
newToken, err := tokenSrc.Token()
if err != nil {
http.Error(w, "failed to refresh token", http.StatusUnauthorized)
return
}
// Save new tokens
newRawIDToken, ok := newToken.Extra("id_token").(string)
if !ok {
http.Error(w, "missing id_token", http.StatusUnauthorized)
return
}
idToken, claims, err = verifyTokenAndGetClaims(config, r.Context(), newRawIDToken)
if err != nil {
http.Error(w, "invalid refreshed token", http.StatusUnauthorized)
return
}
session.Values["id_token"] = newRawIDToken
if newToken.RefreshToken != "" {
session.Values["refresh_token"] = newToken.RefreshToken
}
if scope, ok := newToken.Extra("scope").(string); ok {
session.Values["scope"] = scope
}
session.Save(r, w)
serveWithTokenAndClaims(next, r, w, claims, idToken)
return
})
}
return middleware, nil
}

View File

@@ -0,0 +1,64 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
// "github.com/getsentry/sentry-go"
"clintonambulance.com/calculate_negative_points/internal/utils"
"github.com/swaggest/rest"
"github.com/swaggest/rest/nethttp"
)
func ErrorResponder(w http.ResponseWriter, error string, code int) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(utils.NewApplicationError(error, code))
}
type errorWithFields interface {
error
Fields() map[string]interface{}
}
// ErrorHandler middleware centralizes error handling and translation of errors between different error types
func ErrorHandler(next http.Handler) http.Handler {
var h *nethttp.Handler
if nethttp.HandlerAs(next, &h) {
resp := func(ctx context.Context, err error) (int, interface{}) {
// Handle AWS errors
// Application-specific error handling: if this error has a marker interface, serialize it as JSON
var customErr utils.CustomApplicationError
if errors.As(err, &customErr) {
return customErr.HTTPStatus(), customErr
}
// More detailed validation error messages
var validationErr errorWithFields
if errors.As(err, &validationErr) {
detailedErrorMessages := make([]string, len(validationErr.Fields()))
i := 0
for k, v := range validationErr.Fields() {
detailedErrorMessages[i] = fmt.Sprintf("%s: %v", k, v)
i++
}
return http.StatusUnprocessableEntity, utils.ApplicationError{
Message: fmt.Sprintf("Validation errors: %s", strings.Join(detailedErrorMessages, ", ")),
}
}
code, er := rest.Err(err)
return code, utils.ApplicationError{
Message: er.ErrorText,
}
}
h.MakeErrResp = resp
}
return next
}

View File

@@ -0,0 +1,77 @@
package middleware
import (
"net/http"
"strings"
"clintonambulance.com/calculate_negative_points/internal/config"
)
type contextKey string
const userContextKey = contextKey("user")
func audMatch(aud interface{}, expected string) bool {
switch v := aud.(type) {
case string:
return v == expected
case []interface{}:
for _, val := range v {
if s, ok := val.(string); ok && s == expected {
return true
}
}
}
return false
}
func JWTMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodDelete {
// Skip auth for safe methods like GET/HEAD
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
// Check if the header exists and starts with "Bearer "
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized: Bearer token missing or invalid", http.StatusUnauthorized)
return
}
// Extract the token by removing the "Bearer " prefix
//tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
//token, err := jwt.Parse(tokenStr, config.Jwt.KeySet.Keyfunc)
//if err != nil || !token.Valid {
// http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
// return
//}
//claims, ok := token.Claims.(jwt.MapClaims)
//if !ok {
// http.Error(w, "Invalid token claims", http.StatusUnauthorized)
// return
//}
// Check `iss` and `aud`
// if claims["iss"] != config.Jwt.Issuer {
// http.Error(w, "Invalid issuer", http.StatusUnauthorized)
// return
// }
// audClaim := claims["aud"]
// if !audMatch(audClaim, config.Jwt.Audience) {
// http.Error(w, "Invalid audience", http.StatusUnauthorized)
// return
// }
//ctx := context.WithValue(r.Context(), userContextKey, claims)
next.ServeHTTP(w, r.WithContext(r.Context()))
})
}
return middleware, nil
}

View File

@@ -0,0 +1,50 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type ContextKey string
const (
LoggerContext = ContextKey("logger")
)
func LoggingMiddleware(logger *zap.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
defer func() {
if ww.Status() < 300 && strings.HasPrefix(r.URL.Path, "/health") {
// Don't log health checks unless they fail (ping endpoint returns empty response, HTTP status 204).
return
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
fields := []zapcore.Field{
zap.Any("body", r.Body),
zap.String("remote_ip", r.RemoteAddr),
zap.String("method", r.Method),
zap.String("uri", r.URL.Path),
zap.String("request_id", middleware.GetReqID(r.Context())),
zap.Int("status", ww.Status()),
zap.Float64("latency_ms", float64(time.Since(start))/float64(time.Millisecond)),
zap.Int("size", ww.BytesWritten()),
}
logger.Info("HTTP request processed", fields...)
}()
r = r.WithContext(context.WithValue(r.Context(), LoggerContext, logger))
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
)
func Logout(config *config.ApplicationConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
http.Error(w, "Failed to delete session", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
return
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,36 @@
package middleware
import (
"context"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
)
func OidcMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
rawIDToken, ok := session.Values["id_token"].(string)
if !ok {
// Not authenticated; redirect to login
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
idToken, _, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
if err != nil {
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
// Add token to context
ctx := context.WithValue(r.Context(), "id_token", idToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
return middleware, nil
}

View File

@@ -0,0 +1,48 @@
package middleware
import (
"context"
"net/http"
"strconv"
)
type Pagination struct {
Page int
PageSize int
}
const PaginationContextKey = "pagination"
func PaginationMiddleware() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
page := 1
pageSize := 20
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed >= 1 {
page = parsed
}
}
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed >= 1 {
pageSize = parsed
}
}
if pageSize > 100 {
pageSize = 100
}
ctx := context.WithValue(r.Context(), PaginationContextKey, Pagination{
Page: page,
PageSize: pageSize,
})
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
}