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)
}
}

View File

@@ -0,0 +1,36 @@
package views_api
import (
"log"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
"clintonambulance.com/calculate_negative_points/internal/api/requests/negative_points_processor"
"clintonambulance.com/calculate_negative_points/internal/api/requests/users"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/swaggest/rest/nethttp"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
func MountInternalApiEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
e.Route("/api", func(r chi.Router) {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
}))
currentUserMiddlware, err := middleware.CurrentUserMiddleware(config)
if err != nil {
log.Fatal(err)
}
r.Use(currentUserMiddlware)
r.Method(http.MethodGet, "/users/current", nethttp.NewHandler(users.GetCurrentUser()))
r.Method(http.MethodPost, "/process", nethttp.NewHandler(negative_points_processor.NegativePointsProcessor(config, logger)))
r.With(middleware.Logout(config)).Method(http.MethodDelete, "/users/current", nethttp.NewHandler(users.Logout()))
})
}

View File

@@ -0,0 +1,139 @@
package negative_points_processor
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/nocodb"
"clintonambulance.com/calculate_negative_points/internal/utils"
"github.com/samber/lo"
"github.com/swaggest/usecase"
"go.uber.org/zap"
)
type negativePointsProcessorInput struct {
File *multipart.FileHeader `formData:"file" description:"XLS schedule file to process"`
}
type negativePointsProcessorOutput struct {
Employees []string `json:"employees" description:"List of employees who had negative points" nullable:"false" required:"true"`
}
func hasMatch(record nocodb.NocoDBRecord, candidates []string, threshold int) bool {
normalizedName := utils.NormalizeName(utils.ToTitleCase(record.Name))
bestDistance := threshold + 1
for _, candidate := range candidates {
normalizedCandidate := utils.NormalizeName(candidate)
distance := utils.LevenshteinDistance(normalizedName, normalizedCandidate)
if distance < bestDistance {
bestDistance = distance
}
}
if bestDistance <= threshold {
return true
}
return false
}
func NegativePointsProcessor(config *config.ApplicationConfig, logger *zap.Logger) usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input negativePointsProcessorInput, output *negativePointsProcessorOutput) error {
ctxUser := ctx.Value("claims").(middleware.Claims)
file, err := input.File.Open()
if err != nil {
return err
}
defer file.Close()
employees, err := utils.ParseUploadedXLSFile(input.File)
if err != nil {
return err
}
records, err := nocodb.Fetch(config)
if err != nil {
return err
}
employees = lo.Filter(employees, func(e utils.Employee, _ int) bool { return e.Worked() })
names := lo.Map(employees, func(e utils.Employee, _ int) string { return e.Name })
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
xlsNamesConverted := lo.Map(names, func(name string, _ int) string {
return utils.ToTitleCase(utils.ConvertNameFormat(name))
})
overlaps := lo.Filter(records, func(r nocodb.NocoDBRecord, _ int) bool {
return hasMatch(r, xlsNamesConverted, config.MatchThreshold)
})
currentNocodbUser, found := lo.Find(records, func(r nocodb.NocoDBRecord) bool { return hasMatch(r, []string{ctxUser.Name}, config.MatchThreshold) })
if !found {
return errors.New("Unable to match API user to NocoDB user")
}
requestObjects := lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) nocodb.NocoDBRequest {
return nocodb.NocoDBRequest{
EmployeeId: r.ID,
ReportedBy: currentNocodbUser.ID,
InfractionId: config.NocoDBConfig.NegativeInfractionId,
Date: utils.FirstDayOfMonth(),
}
})
// Marshal request objects to JSON
jsonData, err := json.Marshal(requestObjects)
if err != nil {
return err
}
// Create POST request
req, err := http.NewRequest("POST", nocodb.AddInfractionsUrl(config), strings.NewReader(string(jsonData)))
if err != nil {
return err
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
output.Employees = lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) string { return r.Name })
return nil
})
u.SetDescription("Process Negative Points")
u.SetTags("Negative Points Processor")
return u
}

View File

@@ -0,0 +1,31 @@
package users
import (
"context"
"go/types"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
internal_types "clintonambulance.com/calculate_negative_points/internal/types"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/samber/lo"
"github.com/swaggest/usecase"
)
func GetCurrentUser() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *internal_types.UiUserResponse) error {
ctxId := ctx.Value("user").(*oidc.IDToken)
ctxUser := ctx.Value("claims").(middleware.Claims)
if lo.IsNotEmpty(ctxId.Issuer) {
output.Item = internal_types.UiUser{
Name: ctxUser.Name,
}
}
return nil
})
u.SetDescription("Retrieve the current user")
u.SetTags("Users")
return u
}

View File

@@ -0,0 +1,17 @@
package users
import (
"context"
"go/types"
"github.com/swaggest/usecase"
)
func Logout() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *map[string]interface{}) error {
return nil
})
u.SetDescription("Logout current user")
u.SetTags("Users")
return u
}

335
internal/config/config.go Normal file
View File

@@ -0,0 +1,335 @@
package config
import (
"context"
"fmt"
"log"
"net/http"
"os"
"reflect"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/dsa0x/sicher"
"github.com/go-playground/validator/v10"
"github.com/go-viper/mapstructure/v2"
"github.com/gorilla/sessions"
"github.com/joho/godotenv"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/samber/lo"
"github.com/spf13/pflag"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
const defaultEnvironment = "development"
type SicherObject interface {
SetEnvStyle(string)
LoadEnv(string, interface{}) error
}
type SicherConfig struct {
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
}
var NewSicherObject = func(environment string, path string) SicherObject {
return sicher.New(environment, path)
}
type OidcConfig struct {
ClientId string `koanf:"oidc.client_id" validate:"required"`
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
Issuer string `koanf:"oidc.issuer" validate:"required"`
Scopes []string `koanf:"oidc.scopes" validate:"required"`
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
OAuth2 *oauth2.Config
}
type IdmsConfig struct {
BaseUrl string `koanf:"idms.base_url" validate:"required"`
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
}
type NocoDBConfig struct {
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
}
type ApplicationConfig struct {
AppSecret Secret[string] `koanf:"app_secret"`
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
CookieStore *sessions.CookieStore
Environment string `koanf:"environment" validate:"required"`
Listen string `koanf:"listen" validate:"required,hostname_port"`
MatchThreshold int `koanf:"match_threshold" validate:"required"`
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
PublicPath string `koanf:"paths.public"`
Idms IdmsConfig `koanf:"idms" validate:"required"`
SessionName string
ViewPath string `koanf:"paths.views"`
}
type ParsedCliArgs struct {
ConfigFiles *[]string
DatabasePasswordPath *string
Environment *string
Listen *string
Version *bool
}
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
f.Usage = func() {
fmt.Println(version.AppNameAndVersion(false))
fmt.Println(f.FlagUsages())
exit(0)
}
parsedCliArgs := &ParsedCliArgs{
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
Version: f.Bool("version", false, "Show version"),
}
err := f.Parse(args[1:])
if err != nil {
_, _ = fmt.Fprintf(
os.Stderr,
"%s\n\nERROR: %s\n\n%s",
version.AppNameAndVersion(false), err, f.FlagUsages(),
)
exit(1)
}
return parsedCliArgs, f
}
// Load application configuration from config files, command line flags, and environment variables.
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
k := koanf.New(".")
config := defaultConfig()
err := k.Load(confmap.Provider(config, "."), nil)
if err != nil {
log.Fatal("error initializing config", zap.Error(err))
}
parsedCliArgs, f := ParseArgs(version, exit, args)
// Load the config files provided in the command line.
for _, c := range *parsedCliArgs.ConfigFiles {
err := k.Load(file.Provider(c), yaml.Parser())
if err != nil {
return nil, fmt.Errorf("error loading configuration file: %v", err)
}
}
err = godotenv.Load()
if err != nil {
logger.Info("No .env file found")
}
configPath := func(p string) string {
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
return combinedPath
}
// Override with environment variables
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
logger.Fatal("error parsing environment variables", zap.Error(err))
}
})
// Override with command-line values
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
}
if err := k.Unmarshal("", &config); err != nil {
return nil, err
}
if *parsedCliArgs.Version {
fmt.Println(version.AppNameAndVersion(true))
exit(0)
}
var sicherConfig SicherConfig
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
s.SetEnvStyle("yaml") // default is dotenv
err = s.LoadEnv("", &sicherConfig)
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
cookieStore.Options.SameSite = http.SameSiteLaxMode
if err != nil {
log.Println(err)
}
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
}
var appConfig ApplicationConfig
unmarshalConf := koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
SecretFilePathUnmarshalHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
),
Metadata: nil,
Result: &appConfig,
WeaklyTypedInput: true,
},
}
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
return nil, err
}
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
nocodbConfig := NocoDBConfig{
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
BaseUrl: k.String("nocodb.base_url"),
EmployeesTableId: k.String("nocodb.employees_table_id"),
InfractionsTableId: k.String("nocodb.infractions_table_id"),
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
NoPointsViewId: k.String("nocodb.no_points_view_id"),
}
oidcConfig := OidcConfig{
ClientId: k.String("oidc.client_id"),
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
Issuer: k.String("oidc.issuer"),
Scopes: k.Strings("oidc.scopes"),
}
if *parsedCliArgs.Environment != "testEnvironment" {
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
if err != nil {
return nil, err
}
oidcConfig.Provider = authProvider
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
oidcConfig.OAuth2 = &oauth2.Config{
ClientID: k.String("oidc.client_id"),
ClientSecret: k.String("oidc.client_secret"),
Endpoint: authProvider.Endpoint(),
RedirectURL: redirectUrl,
Scopes: k.Strings("oidc.scopes"),
}
}
appConfig.CookieStore = cookieStore
appConfig.Environment = *parsedCliArgs.Environment
appConfig.Idms = IdmsConfig{
BaseUrl: k.String("idms.base_url"),
CarsGroupId: k.String("idms.cars_group_id"),
Id: SecretFromValue(k.String("idms.service_account_id")),
Password: SecretFromValue(k.String("idms.service_account_password")),
}
appConfig.Listen = *parsedCliArgs.Listen
appConfig.MatchThreshold = k.Int("match_threshold")
appConfig.NocoDBConfig = nocodbConfig
appConfig.OidcConfig = oidcConfig
appConfig.PublicPath = configPath(k.String("paths.public"))
appConfig.SessionName = "calculate-negative-points"
appConfig.ViewPath = configPath(k.String("paths.views"))
validate := validator.New()
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
err = validate.Struct(appConfig)
if err != nil {
log.Println(err)
fmt.Print("Could not create config")
exit(1)
return nil, err
}
return &appConfig, nil
}
const envPrefix = "APP_"
const pgPrefix = "PG_"
const databasePrefix = "DATABASE_"
const idmsPrefix = "IDMS_"
const oidcPrefix = "OIDC_"
/*
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
*/
func mapEnvVarNames(prefix string) func(s string) string {
return func(s string) string {
if prefix == databasePrefix {
return strings.ToLower(s)
}
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
return lower
}
}
func defaultConfig() map[string]interface{} {
dbConfig := map[string]interface{}{
"host": "localhost",
"port": 5432,
"user": "postgres",
"name": "calculate-negative-points",
}
return map[string]interface{}{
"database": dbConfig,
"environment": defaultEnvironment,
"listen": "127.0.0.1:3000",
}
}
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
if secret, ok := field.Interface().(Secret[T]); ok {
return secret.Value()
}
return nil
}

View File

@@ -0,0 +1,13 @@
package config_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestConfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config Suite")
}

View File

@@ -0,0 +1,41 @@
package config_test
import (
"log"
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type FakeSicherObject struct{}
func (_ FakeSicherObject) SetEnvStyle(_ string) {}
func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error {
var err error
return err
}
var _ = Describe("ConfigTest", func() {
var version config.Version
var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
shouldNotExit := func(code int) {
// "Fatal" to mimic os.Exit
log.Default().Print("exit called with code", code)
}
BeforeEach(func() {
config.NewSicherObject = func(_ string, _ string) config.SicherObject {
return FakeSicherObject{}
}
version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"}
})
It("Errors if the config file is not found", func() {
argv := []string{"cmd", "-c", "config/no-such-file.yml"}
_, err := config.Load(version, shouldNotExit, argv, logger)
Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory"))
})
})

View File

@@ -0,0 +1,34 @@
package config
import (
"io"
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) {
parsedCliArgs, _ := ParseArgs(version, os.Exit, args)
syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zap.CombineWriteSyncers(syncs...),
zap.DebugLevel,
)
logger := zap.New(core).WithOptions(
zap.AddStacktrace(zap.ErrorLevel),
zap.WithCaller(false),
).With(
zap.String("version", version.Release),
zap.String("environment", *parsedCliArgs.Environment),
)
finalizer := func() {
// This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880)
_ = logger.Sync()
}
return logger, finalizer
}

85
internal/config/secret.go Normal file
View File

@@ -0,0 +1,85 @@
package config
import (
"bytes"
"encoding/json"
"os"
"reflect"
"github.com/mitchellh/mapstructure"
)
// SecretValue defines what types of secrets this secret container supports
type SecretValue interface {
~string | ~[]string | ~map[string]string
}
// SecretFilePath is the source to the file containing the secret.
type SecretFilePath string
const SecretPlaceholder = "[SECRET]"
// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable
type Secret[T SecretValue] struct {
value T
source SecretFilePath
}
// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc.
func (s Secret[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)})
}
// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc.
func (s *Secret[T]) MarshalText() ([]byte, error) {
return []byte(SecretPlaceholder), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values
func (s *Secret[T]) UnmarshalText(text []byte) error {
var val T
switch any(val).(type) {
case string:
s.value = any(string(text)).(T)
return nil
default:
return json.Unmarshal(text, &s.value)
}
}
func (s Secret[T]) Value() T {
return s.value
}
func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) {
contentBytes, err := os.ReadFile(string(path))
if err != nil {
return Secret[T]{}, err
}
secret := Secret[T]{source: path}
err = secret.UnmarshalText(bytes.TrimSpace(contentBytes))
return secret, err
}
func SecretFromValue[T SecretValue](value T) Secret[T] {
return Secret[T]{value: value}
}
// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret
func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType {
return func(from, to reflect.Type, data interface{}) (interface{}, error) {
if from != reflect.TypeOf(SecretFilePath("")) {
return data, nil
}
// Reflection does not work with generics as of 1.20, so we have to do this manually
if to == reflect.TypeOf(Secret[map[string]string]{}) {
return SecretFromSecretPath[map[string]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[[]string]{}) {
return SecretFromSecretPath[[]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[string]{}) {
return SecretFromSecretPath[string](data.(SecretFilePath))
} else {
return data, nil
}
}
}

View File

@@ -0,0 +1 @@
fakesecret

View File

@@ -0,0 +1,22 @@
app_secret: "app secret"
app_secret_block: "app secret block hash"
path_prefix: "."
paths:
migrations: "db/migrations"
credentials: "config/credentials"
match_threshold: 3
nocodb:
api_token: test
base_url: https://example.com
employees_table_id: "1234567890"
infractions_table_id: "2468013579"
negative_infraction_id: 1
no_points_view_id: "1357924680"
oidc:
issuer: http://example.com
redirect_url: http://example.com
idms:
base_url: https://example.com
cars_group_id: "1234"
id: "1234"
password: "5678"

View File

@@ -0,0 +1,34 @@
package config
import "fmt"
type Version struct {
Release string
Commit string
Date string
}
var (
release = "dev"
commit = ""
date = ""
)
func (v *Version) AppNameAndVersion(details bool) string {
result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release)
if details && v.Commit != "" {
result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit)
}
if details && v.Date != "" {
result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date)
}
return result
}
func NewVersion() Version {
return Version{
Release: release,
Commit: commit,
Date: date,
}
}

103
internal/idms/request.go Normal file
View File

@@ -0,0 +1,103 @@
package idms
import (
"fmt"
"clintonambulance.com/calculate_negative_points/internal/config"
"resty.dev/v3"
)
type Method string
const Get Method = "GET"
const Post Method = "POST"
const Put Method = "PUT"
const Delete Method = "Delete"
type ResponsePagination struct {
Count int `json:"count"`
Current int `json:"current"`
EndIndex int `json:"end_index"`
Next int `json:"next"`
Previous int `json:"previous"`
StartIndex int `json:"start_index"`
TotalPages int `json:"total_pages"`
}
type IdmsResponse[T any] struct {
Pagination ResponsePagination `json:"pagination"`
Results []T `json:"results"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
}
type IdmsRequest[T any] struct {
Method Method
Path string
ResponseDecoder *IdmsResponse[T]
}
func newRestyRequest(url string, path string) *resty.Request {
client := resty.New()
formattedUrl := fmt.Sprintf("%s%s", url, path)
req := client.R()
req.SetURL(formattedUrl)
defer client.Close()
return req
}
func GetToken(config *config.ApplicationConfig) (string, error) {
req := newRestyRequest(config.Idms.BaseUrl, "/application/o/token/")
req.SetResult(&TokenResponse{})
req.SetMethod(string(Post))
req.SetFormData(map[string]string{
"client_id": config.Idms.Id.Value(),
"grant_type": "client_credentials",
"password": config.Idms.Password.Value(),
"scope": "goauthentik.io/api",
"username": "service",
})
res, err := req.Send()
if err != nil {
return "", err
}
data := res.Result().(*TokenResponse).AccessToken
return data, nil
}
func NewRequest[T any](path string, method Method) IdmsRequest[T] {
return IdmsRequest[T]{
Method: method,
Path: path,
ResponseDecoder: &IdmsResponse[T]{},
}
}
func (r IdmsRequest[T]) Perform(config *config.ApplicationConfig, bodyData interface{}, headers map[string]string) ([]T, error) {
req := newRestyRequest(config.Idms.BaseUrl, r.Path)
token, err := GetToken(config)
if err != nil {
return []T{}, err
}
req.SetResult(r.ResponseDecoder).SetAuthToken(token).SetContentType("application/json")
req.SetMethod(string(r.Method))
res, err := req.Send()
if err != nil {
return []T{}, err
}
data := res.Result().(*IdmsResponse[T]).Results
return data, nil
}

74
internal/idms/users.go Normal file
View File

@@ -0,0 +1,74 @@
package idms
import (
"encoding/json"
"fmt"
"sort"
"strings"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/samber/lo"
)
// FlexibleString handles JSON values that can be either strings or numbers
type FlexibleString string
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*f = FlexibleString(s)
return nil
}
var n json.Number
if err := json.Unmarshal(data, &n); err == nil {
*f = FlexibleString(n.String())
return nil
}
return fmt.Errorf("cannot unmarshal %s into FlexibleString", data)
}
type IdmsUserAttributes struct {
Cars bool `json:"cars"`
EmployeeId FlexibleString `json:"employee_id"`
}
type IdmsUser struct {
Attributes IdmsUserAttributes `json:"attributes"`
Groups []string `json:"groups"`
Id string `json:"uid"`
Name string `json:"name"`
Username string `json:"username"`
}
func (u IdmsUser) FirstName() string {
parts := strings.Fields(u.Name)
if len(parts) == 0 {
return ""
}
return parts[0]
}
func (u IdmsUser) LastName() string {
parts := strings.Fields(u.Name)
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func GetCarsMembers(config *config.ApplicationConfig) []IdmsUser {
req := NewRequest[IdmsUser]("/api/v3/core/users/?is_active=true", Get)
results, _ := req.Perform(config, map[string]string{}, map[string]string{})
filtered := lo.Filter(results, func(u IdmsUser, _ int) bool {
return lo.Contains(u.Groups, config.Idms.CarsGroupId)
})
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].LastName() < filtered[j].LastName()
})
return filtered
}

View File

@@ -0,0 +1,13 @@
package models_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestModels(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Models Suite")
}

108
internal/nocodb/request.go Normal file
View File

@@ -0,0 +1,108 @@
package nocodb
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"clintonambulance.com/calculate_negative_points/internal/config"
)
type NocoDBRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
}
// NocoDBResponse represents the API response structure
type NocoDBResponse struct {
List []NocoDBRecord `json:"list"`
PageInfo struct {
TotalRows int `json:"totalRows"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
IsFirstPage bool `json:"isFirstPage"`
IsLastPage bool `json:"isLastPage"`
} `json:"pageInfo"`
}
type NocoDBRequest struct {
EmployeeId int `json:"Employees_id"`
ReportedBy int `json:"Employees_id1"`
InfractionId int `json:"Infractions_id"`
Date string `json:"Date"`
}
func NoPointsUrl(config *config.ApplicationConfig) string {
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.EmployeesTableId, "records")
return fmt.Sprintf("%s?viewId=%s", combinedUrl, config.NocoDBConfig.NoPointsViewId)
}
func AddInfractionsUrl(config *config.ApplicationConfig) string {
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.InfractionsTableId, "records")
return combinedUrl
}
func Fetch(config *config.ApplicationConfig) ([]NocoDBRecord, error) {
records := []NocoDBRecord{}
offset := 0
limit := 25
isLastPage := false
baseURL := NoPointsUrl(config)
for !isLastPage {
// Create HTTP request with base URL
req, err := http.NewRequest("GET", baseURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set query params via the request's URL
q := req.URL.Query()
q.Set("offset", fmt.Sprintf("%d", offset))
q.Set("limit", fmt.Sprintf("%d", limit))
req.URL.RawQuery = q.Encode()
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
// Read and parse response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var nocoDBResp NocoDBResponse
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}
// Extract names from records
records = append(records, nocoDBResp.List...)
// Check if we've reached the last page
isLastPage = nocoDBResp.PageInfo.IsLastPage
// Update offset for next page
offset += limit
}
return records, nil
}

64
internal/server/http.go Normal file
View File

@@ -0,0 +1,64 @@
package server
import (
"encoding/json"
"net/http"
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
views_api "clintonambulance.com/calculate_negative_points/internal/api/requests"
"clintonambulance.com/calculate_negative_points/internal/config"
internal_web "clintonambulance.com/calculate_negative_points/internal/web"
"github.com/go-chi/chi/v5/middleware"
"github.com/swaggest/openapi-go/openapi31"
"github.com/swaggest/rest/response"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
type ErrorResponse struct {
Message string `json:"message"`
}
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(ErrorResponse{Message: error})
}
func notFoundResponder(w http.ResponseWriter, _r *http.Request) {
errorResponder(w, "Not Found", http.StatusNotFound)
}
func NewHttpServer(logger *zap.Logger, version config.Version) (*web.Service, *openapi31.Reflector) {
reflector := openapi31.NewReflector()
service := web.NewService(reflector)
service.OpenAPISchema().SetTitle("Calculate Negative Points")
service.OpenAPISchema().SetDescription("Easily maintain Calculate Negative Points")
service.OpenAPISchema().SetVersion("v1.0.0")
service.Use(
middleware.RealIP,
middleware.RequestID,
apimiddleware.AddRequestIDHeaderMiddleware,
apimiddleware.LoggingMiddleware(logger),
apimiddleware.ErrorHandler,
)
service.NotFound(notFoundResponder)
return service, reflector
}
func MountAllEndpoints(srv *web.Service, version config.Version, config *config.ApplicationConfig, logger *zap.Logger) {
views_api.MountInternalApiEndpoints(srv, config, logger)
// This should be last because it does wildcard matching
internal_web.MountWebEndpoints(srv, config, logger)
}
func init() {
// Override some global defaults:
// This is just "application/json" by default, let's include the charset as well.
response.DefaultSuccessResponseContentType = "application/json; charset=UTF-8"
}

View File

@@ -0,0 +1,51 @@
package server_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestServer(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Http Suite")
}
var _ = Describe("NewHttpServer", func() {
It("Creates a new HttpServer", func() {
var logOutput bytes.Buffer
version := config.Version{Release: "test-version"}
logger, _ := config.NewLogger(version, &logOutput, []string{"cmd", "-e", "testEnvironment"})
srv, _ := server.NewHttpServer(logger, version)
srv.Method(http.MethodHead, "/dummy-route", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
rec := httptest.NewRecorder()
// Validate that the server responds with the web frontend for unknown routes
srv.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusNotFound))
Expect(rec.Header().Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(rec.Body.String()).To(ContainSubstring(`{"message":"Not Found"}`))
// Validate that it writes to the log for all requests
var logMessage map[string]interface{}
err := json.Unmarshal(logOutput.Bytes(), &logMessage)
Expect(err).ToNot(HaveOccurred())
Expect(logMessage["msg"]).To(Equal("HTTP request processed"))
Expect(logMessage["method"]).To(Equal("GET"))
Expect(logMessage["uri"]).To(Equal("/foobar"))
Expect(logMessage["status"]).To(Equal(404.0))
})
})

View File

@@ -0,0 +1,130 @@
package test
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
"github.com/onsi/gomega/ghttp"
"github.com/swaggest/rest/web"
)
var version = config.Version{Release: "test-version", Commit: "test-commit"}
func CreateRequestBodyFromStruct(body interface{}) *bytes.Buffer {
return bytes.NewBuffer(CreateResponseBodyFromStruct(body))
}
func CreateResponseBodyFromStruct(body interface{}) []byte {
jsonValue, err := json.Marshal(body)
if err != nil {
log.Fatal("Error marshaling struct")
}
return jsonValue
}
func URLFromServerAndPath(server *ghttp.Server, path string) string {
return fmt.Sprintf("%s%s", server.URL(), path)
}
func CreateHttpServer(appConfig *config.ApplicationConfig) *web.Service {
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
defer flushLogs()
service, _ := server.NewHttpServer(logger, version)
server.MountAllEndpoints(service, version, appConfig, logger)
return service
}
// TestClaims represents the mock user claims for testing
type TestClaims struct {
Sub string
Name string
Email string
Groups []string
}
// DefaultTestClaims returns default test user claims
func DefaultTestClaims() TestClaims {
return TestClaims{
Sub: "test-user-id",
Name: "Test User",
Email: "test@example.com",
Groups: []string{"calculate-negative-points-users"},
}
}
// addMockSessionCookie adds a mock session cookie to the request with the given claims
func addMockSessionCookie(req *http.Request, appConfig *config.ApplicationConfig) {
// Create a mock session with a fake id_token
session, _ := appConfig.CookieStore.New(req, appConfig.SessionName)
session.Values["id_token"] = "mock-test-token"
session.Values["refresh_token"] = "mock-refresh-token"
// Encode the session to a cookie
rec := httptest.NewRecorder()
session.Save(req, rec)
// Copy the Set-Cookie header to the request as a Cookie header
for _, cookie := range rec.Result().Cookies() {
req.AddCookie(cookie)
}
}
func PerformHttpRequest(method string, path string, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
return PerformHttpRequestWithClaims(method, path, DefaultTestClaims(), requestParams...)
}
func PerformHttpRequestWithClaims(method string, path string, claims TestClaims, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
var body map[string]interface{}
headers := map[string]string{}
query := map[string]string{}
appConfig := CreateTestConfig()
srv := CreateHttpServer(appConfig)
for i, arg := range requestParams {
switch i {
case 0:
body = arg
case 1:
for header, value := range arg {
headers[header] = fmt.Sprintf("%v", value)
}
case 2:
for key, value := range arg {
query[key] = fmt.Sprintf("%v", value)
}
default:
panic("Unknown argument")
}
}
req := httptest.NewRequest(method, path, CreateRequestBodyFromStruct(body))
q := req.URL.Query()
for key, value := range query {
q.Add(key, value)
}
req.URL.RawQuery = q.Encode()
for header, value := range headers {
req.Header.Set(header, value)
}
// Add mock session cookie for authentication
addMockSessionCookie(req, appConfig)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}

View File

@@ -0,0 +1,53 @@
package test
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/gorilla/sessions"
"github.com/onsi/ginkgo/v2"
)
func CreateTestConfig() *config.ApplicationConfig {
appSecret := "12345678901234567890123456789012"
appSecretBlock := "09876543210987654321098765431098"
basepath := os.Getenv("APP_MIGRATION_DIRECTORY")
if basepath == "" {
_, b, _, _ := runtime.Caller(0)
basepath = filepath.Dir(b)
}
return &config.ApplicationConfig{
AppSecret: config.SecretFromValue(appSecret),
AppSecretBlock: config.SecretFromValue(appSecretBlock),
CookieStore: sessions.NewCookieStore([]byte(appSecret)),
Environment: "test",
Listen: "127.0.0.1:3000",
MatchThreshold: 3,
SessionName: "calculate-negative-points-test",
NocoDBConfig: config.NocoDBConfig{
ApiToken: config.SecretFromValue("1234567890"),
BaseUrl: "https://example.com",
EmployeesTableId: "0987654321",
InfractionsTableId: "2468013579",
NegativeInfractionId: 1,
NoPointsViewId: "1357924680",
},
}
}
func UnmarshalBody[K comparable, V any](rec *httptest.ResponseRecorder) map[K]V {
var body map[K]V
err := json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
ginkgo.Fail("Could not decode response")
}
return body
}

28
internal/types/generic.go Normal file
View File

@@ -0,0 +1,28 @@
package types
// PaginationRequest is a standard pagination input for API requests
type PaginationRequest struct {
Page int `json:"page" query:"page" default:"1" minimum:"1"`
PageSize int `json:"page_size" query:"page_size" default:"20" minimum:"1" maximum:"100"`
}
// PaginationResponse is a standard pagination output for API responses
type PaginationResponse struct {
Page int `json:"page" required:"true"`
PageSize int `json:"page_size" required:"true"`
Total int `json:"total" required:"true"`
TotalPages int `json:"total_pages" required:"true"`
}
type PaginatedIndexResponse[T any] struct {
Items []T `json:"items" required:"true" nullable:"false"`
Pagination PaginationResponse `json:"pagination" required:"true" nullable:"false"`
}
type IndexResponse[T any] struct {
Items []T `json:"items" required:"true" nullable:"false"`
}
type ShowResponse[T any] struct {
Item T `json:"item" required:"true" nullable:"false"`
}

View File

@@ -0,0 +1,9 @@
package types
type UiPayrollCategory struct {
Name string `json:"name" required:"true"`
Rate float64 `json:"rate" required:"true" format:"double"`
Id int `json:"id" required:"true"`
}
type UiPayrollCategoriesResponse = IndexResponse[UiPayrollCategory]

View File

@@ -0,0 +1,11 @@
package types
type UiPayrollEntry struct {
Category UiPayrollCategory `json:"category" required:"true" nullable:"false"`
Date string `json:"date" format:"iso8601"`
Description string `json:"description"`
Employee UiUser `json:"employee" required:"true" nullable:"false"`
Quantity float64 `json:"quantity" format:"double" required:"true"`
}
type UiPayrollEntriesResponse = PaginatedIndexResponse[UiPayrollEntry]

24
internal/types/users.go Normal file
View File

@@ -0,0 +1,24 @@
package types
import "clintonambulance.com/calculate_negative_points/internal/idms"
type UiUser struct {
FirstName string `json:"first_name"`
Groups []string `json:"groups,omitempty" required:"false"`
Id string `json:"id" required:"true" nullable:"false"`
LastName string `json:"last_name"`
Name string `json:"name" required:"true" nullable:"false"`
}
type UiUsersResponse = IndexResponse[UiUser]
type UiUserResponse = ShowResponse[UiUser]
func UiUserFromIdmsUser(u idms.IdmsUser) UiUser {
return UiUser{
FirstName: u.FirstName(),
Groups: u.Groups,
Id: string(u.Attributes.EmployeeId),
LastName: u.LastName(),
Name: u.Name,
}
}

View File

@@ -0,0 +1,13 @@
package utils
import "strings"
func ConvertNameFormat(name string) string {
parts := strings.Split(name, ",")
if len(parts) == 2 {
lastName := strings.TrimSpace(parts[0])
firstName := strings.TrimSpace(parts[1])
return firstName + " " + lastName
}
return name
}

43
internal/utils/error.go Normal file
View File

@@ -0,0 +1,43 @@
package utils
import (
"net/http"
"github.com/swaggest/rest"
)
// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema.
type CustomApplicationError interface {
rest.ErrWithHTTPStatus
CustomApplicationError()
}
type ApplicationError struct {
Message string `json:"message"`
code int
}
var (
_ CustomApplicationError = ApplicationError{}
)
func NewApplicationError(message string, code int) ApplicationError {
return ApplicationError{
Message: message,
code: code,
}
}
func (e ApplicationError) Error() string {
return e.Message
}
func (e ApplicationError) HTTPStatus() int {
return e.code
}
func (e ApplicationError) CustomApplicationError() {}
func ValidationError(message string) ApplicationError {
return NewApplicationError(message, http.StatusUnprocessableEntity)
}

View File

@@ -0,0 +1,20 @@
package utils
import "time"
func FirstDayOfMonth() string {
// Load America/Detroit timezone
loc, err := time.LoadLocation("America/Detroit")
if err != nil {
panic(err)
}
// Get current time in Detroit timezone
now := time.Now().In(loc)
// Create midnight of the first day of the current month
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
// Return ISO8601 formatted string
return firstDay.Format(time.RFC3339)
}

View File

@@ -0,0 +1,34 @@
package utils
import "strings"
func LevenshteinDistance(s1, s2 string) int {
s1 = strings.ToLower(s1)
s2 = strings.ToLower(s2)
len1 := len(s1)
len2 := len(s2)
matrix := make([][]int, len1+1)
for i := range matrix {
matrix[i] = make([]int, len2+1)
matrix[i][0] = i
}
for j := range matrix[0] {
matrix[0][j] = j
}
for i := 1; i <= len1; i++ {
for j := 1; j <= len2; j++ {
cost := 0
if s1[i-1] != s2[j-1] {
cost = 1
}
matrix[i][j] = min(
matrix[i-1][j]+1,
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
)
}
}
return matrix[len1][len2]
}

19
internal/utils/logging.go Normal file
View File

@@ -0,0 +1,19 @@
package utils
import (
"fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) {
err := dbOp()
logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation)
if err != nil {
logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName)
}
return logMsg, err
}

12
internal/utils/must.go Normal file
View File

@@ -0,0 +1,12 @@
package utils
import "go.uber.org/zap"
func Must[T any](res T, err error) func(logger *zap.Logger) T {
return func(logger *zap.Logger) T {
if err != nil {
logger.Fatal("Fatal error", zap.Error(err))
}
return res
}
}

View File

@@ -0,0 +1,23 @@
package utils
import (
"regexp"
"strings"
"unicode"
)
func NormalizeName(name string) string {
// Remove extra whitespace
name = strings.TrimSpace(name)
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
// Remove common punctuation but keep hyphens
name = strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
return unicode.ToLower(r)
}
return -1
}, name)
return name
}

View File

@@ -0,0 +1,105 @@
package utils
import (
"fmt"
"io"
"mime/multipart"
"regexp"
"strconv"
"strings"
"github.com/samber/lo"
)
type Shift struct {
EarningCode string `json:"earning_code"`
Description string `json:"description"`
Hours float64 `json:"hours"`
}
func (s Shift) CountsAsWorked() bool {
return strings.ToLower(s.EarningCode) == "reg"
}
type Employee struct {
Name string `json:"name"`
Shifts []Shift `json:"shifts"`
}
func (e Employee) Worked() bool {
return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() })
}
func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) {
f, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open XLS file: %w", err)
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read XLS file: %w", err)
}
return ParseXLSContent(content)
}
// ParseXLSContent parses the HTML content of an XLS file and returns a slice
// of employees with their shifts. The XLS files are HTML tables where:
// - Column A: employee name (td with class="smallbold" and valign=top)
// - Column B: inner table containing shift rows
// - Within the inner table, rows with colspan are category headers (skipped)
// - Shift rows have: spacer td, earning code/description td (class="smalltext"),
// and hours td
func ParseXLSContent(content []byte) ([]Employee, error) {
html := string(content)
// Split by employee rows in the outer table. Each employee row contains
// a name cell (column A) followed by a cell with an inner table (column B).
employeePattern := regexp.MustCompile(
`<td\s+valign=top\s+class="smallbold">([^<]+)</td>\s*<td>(.*?)</table></td>`,
)
employeeMatches := employeePattern.FindAllStringSubmatch(html, -1)
// Pattern for shift data rows: spacer td + earning code td + hours td
// These are rows where column C has class="smalltext" (not a colspan header)
shiftPattern := regexp.MustCompile(
`<tr><td width=25></td><td class=smalltext>([^<]+)</td><td[^>]*>([^<]+)</td></tr>`,
)
employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee {
name := strings.TrimSpace(empMatch[1])
innerTable := empMatch[2]
shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1)
shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) {
codeAndDesc := strings.TrimSpace(sm[1])
hoursStr := strings.TrimSpace(sm[2])
parts := strings.SplitN(codeAndDesc, " - ", 2)
if len(parts) != 2 {
return Shift{}, false
}
hours, err := strconv.ParseFloat(hoursStr, 64)
if err != nil {
return Shift{}, false
}
return Shift{
EarningCode: strings.TrimSpace(parts[0]),
Description: strings.TrimSpace(parts[1]),
Hours: hours,
}, true
})
return Employee{
Name: name,
Shifts: shifts,
}
})
return employees, nil
}

View File

@@ -0,0 +1,77 @@
package utils_test
import (
"os"
"clintonambulance.com/calculate_negative_points/internal/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseXLSContent", func() {
It("parses employees and their shifts from the test XLS file", func() {
content, err := os.ReadFile("testdata/test.xls")
Expect(err).NotTo(HaveOccurred())
employees, err := utils.ParseXLSContent(content)
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(HaveLen(2))
By("parsing the first employee")
Expect(employees[0].Name).To(Equal("Howe, Eugene"))
Expect(employees[0].Worked()).To(BeTrue())
Expect(employees[0].Shifts).To(HaveLen(2))
Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{
EarningCode: "MEET",
Description: "Meetings",
Hours: 1.75,
}))
Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse())
Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{
EarningCode: "Reg",
Description: "Regular Hours",
Hours: 125.75,
}))
Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue())
By("parsing the second employee with multiple shift categories")
Expect(employees[1].Name).To(Equal("User, Test"))
Expect(employees[1].Shifts).To(HaveLen(4))
Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{
EarningCode: "MEET",
Description: "Meetings",
Hours: 2.00,
}))
Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{
EarningCode: "Reg",
Description: "Regular Hours",
Hours: 134.00,
}))
Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{
EarningCode: "Holiday",
Description: "Holiday Hours",
Hours: 8.00,
}))
Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{
EarningCode: "PTO",
Description: "Paid Time Off",
Hours: 12.00,
}))
})
It("skips category header rows (colspan rows)", func() {
content, err := os.ReadFile("testdata/test.xls")
Expect(err).NotTo(HaveOccurred())
employees, err := utils.ParseXLSContent(content)
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(HaveLen(2))
})
It("returns an empty slice for empty content", func() {
employees, err := utils.ParseXLSContent([]byte(""))
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(BeEmpty())
})
})

View File

@@ -0,0 +1,14 @@
package utils
import (
"crypto/rand"
"encoding/hex"
)
func RandomString(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,11 @@
package schemautils
type OpenfeatureFlag struct {
Name string `json:"name" required:"true" description:"Name of the flag"`
Id string `json:"id" required:"true" description:"UUID of the flag"`
Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"`
Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"`
Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"`
Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"`
Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"`
}

View File

@@ -0,0 +1,24 @@
package utils
import "strings"
func ToTitleCase(name string) string {
words := strings.Fields(name)
for i, word := range words {
if len(word) > 0 {
// Handle hyphenated names
if strings.Contains(word, "-") {
parts := strings.Split(word, "-")
for j, part := range parts {
if len(part) > 0 {
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
}
}
words[i] = strings.Join(parts, "-")
} else {
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
}
}
}
return strings.Join(words, " ")
}

View File

@@ -0,0 +1,13 @@
package utils_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestUtils(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Utils Suite")
}

37
internal/views/web.html Normal file
View File

@@ -0,0 +1,37 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calculate Negative Points</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
/>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
{{ if .isDev }}
<script type="module">
import RefreshRuntime from "{{.baseAddress}}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="{{.baseAddress}}/@vite/client"></script>
<script type="module" src="{{.jsFileAddress}}"></script>
{{ else }}
<script type="module" src="/{{.jsFileAddress}}"></script>
{{ range .css }}
<link rel="stylesheet" href="/{{.}}" />
{{ end }}
{{ end }}
</body>
</html>

160
internal/web/web.go Normal file
View File

@@ -0,0 +1,160 @@
package web
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/go-chi/chi/v5"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
type ManifestEntry struct {
Src string `json:"src"`
File string `json:"file"`
IsEntry bool `json:"isEntry"`
Css []string `json:"css"`
}
func withEntryPoint(config *config.ApplicationConfig, ep string, tmplParams map[string]interface{}) {
if config.Environment == "dev" {
tmplParams["jsFileAddress"] = fmt.Sprintf("%s/%s", tmplParams["baseAddress"], ep)
} else {
var parsedManifest map[string]ManifestEntry
manifestContent, err := os.ReadFile(filepath.Join(config.PublicPath, ".vite", "manifest.json"))
if err != nil {
log.Fatal(err)
}
err = json.Unmarshal(manifestContent, &parsedManifest)
if err != nil {
log.Fatal(err)
}
tmplParams["jsFileAddress"] = parsedManifest[ep].File
tmplParams["css"] = parsedManifest[ep].Css
}
}
func MountWebEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
rootResponder := func(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "web.html"))
tmplParams := map[string]interface{}{
"baseAddress": "http://localhost:5173",
"isDev": config.Environment == "dev",
"environment": config.Environment,
}
withEntryPoint(config, "src/main.tsx", tmplParams)
if err != nil {
log.Println(err)
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, tmplParams); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
loggedOutResponder := func(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "logged_out.html"))
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, &struct{}{}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
notAuthorizedResponder := func(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "auth_error.html"))
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, &struct{}{}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
loginHandler := func(w http.ResponseWriter, r *http.Request) {
returnTo := r.URL.Query().Get("returnTo")
if returnTo == "" {
returnTo = "/" // fallback
}
session, _ := config.CookieStore.Get(r, config.SessionName)
session.Values["return_to"] = returnTo
session.Save(r, w)
state := "random-state" // Replace with actual CSRF protection
http.Redirect(w, r, config.OidcConfig.OAuth2.AuthCodeURL(state), http.StatusFound)
}
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := r.URL.Query().Get("code")
token, err := config.OidcConfig.OAuth2.Exchange(ctx, code)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "Missing id_token", http.StatusInternalServerError)
return
}
_, err = config.OidcConfig.Verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "Invalid ID token", http.StatusUnauthorized)
return
}
session, _ := config.CookieStore.Get(r, config.SessionName)
if refreshToken, ok := token.Extra("refresh_token").(string); ok {
session.Values["refresh_token"] = refreshToken
}
returnTo, ok := session.Values["return_to"].(string)
if !ok || returnTo == "" {
returnTo = "/"
}
session.Values["id_token"] = rawIDToken
session.Save(r, w)
http.Redirect(w, r, returnTo, http.StatusFound)
}
e.Wrapper.Get("/users/logged_out", loggedOutResponder)
e.Wrapper.Get("/403", notAuthorizedResponder)
e.Wrapper.Post("/403", notAuthorizedResponder)
e.Route("/", func(r chi.Router) {
r.HandleFunc("/", rootResponder)
r.Get("/auth/login", loginHandler)
r.Get("/auth/callback", callbackHandler)
r.MethodFunc(http.MethodGet, "/*", rootResponder)
})
}