Finished step '1.2.1 Create Shared Package Directory Structure' of the task 'Task 2: Set Up Shared Packages Structure' of the detailed implementation plan @docs/4_work_plan/1.1-development-environment-setup.md
parent
203f76166e
commit
b97644a540
@ -1,17 +1,59 @@
|
|||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
|
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
// Package errors provides domain-specific error types for the KnowFoolery application.
|
||||||
|
package errors
|
||||||
|
|
||||||
|
// ErrorCode represents a unique error code for domain errors.
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
// Error codes for the KnowFoolery application.
|
||||||
|
const (
|
||||||
|
// General errors
|
||||||
|
CodeNotFound ErrorCode = "NOT_FOUND"
|
||||||
|
CodeInvalidInput ErrorCode = "INVALID_INPUT"
|
||||||
|
CodeUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||||
|
CodeForbidden ErrorCode = "FORBIDDEN"
|
||||||
|
CodeConflict ErrorCode = "CONFLICT"
|
||||||
|
CodeInternal ErrorCode = "INTERNAL"
|
||||||
|
|
||||||
|
// Game session errors
|
||||||
|
CodeSessionExpired ErrorCode = "SESSION_EXPIRED"
|
||||||
|
CodeGameInProgress ErrorCode = "GAME_IN_PROGRESS"
|
||||||
|
CodeMaxAttemptsReached ErrorCode = "MAX_ATTEMPTS_REACHED"
|
||||||
|
CodeSessionNotActive ErrorCode = "SESSION_NOT_ACTIVE"
|
||||||
|
|
||||||
|
// Question errors
|
||||||
|
CodeQuestionNotFound ErrorCode = "QUESTION_NOT_FOUND"
|
||||||
|
CodeNoQuestionsAvailable ErrorCode = "NO_QUESTIONS_AVAILABLE"
|
||||||
|
|
||||||
|
// User errors
|
||||||
|
CodeUserNotFound ErrorCode = "USER_NOT_FOUND"
|
||||||
|
CodeUserAlreadyExists ErrorCode = "USER_ALREADY_EXISTS"
|
||||||
|
CodeEmailNotVerified ErrorCode = "EMAIL_NOT_VERIFIED"
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
CodeValidationFailed ErrorCode = "VALIDATION_FAILED"
|
||||||
|
CodeInvalidPlayerName ErrorCode = "INVALID_PLAYER_NAME"
|
||||||
|
CodeInvalidAnswer ErrorCode = "INVALID_ANSWER"
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
CodeInvalidToken ErrorCode = "INVALID_TOKEN"
|
||||||
|
CodeTokenExpired ErrorCode = "TOKEN_EXPIRED"
|
||||||
|
CodeMFARequired ErrorCode = "MFA_REQUIRED"
|
||||||
|
|
||||||
|
// Rate limiting errors
|
||||||
|
CodeRateLimitExceeded ErrorCode = "RATE_LIMIT_EXCEEDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the error code.
|
||||||
|
func (c ErrorCode) String() string {
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
// Package errors provides domain-specific error types for the KnowFoolery application.
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainError represents a domain-level error with an error code.
|
||||||
|
type DomainError struct {
|
||||||
|
Code ErrorCode
|
||||||
|
Message string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
func (e *DomainError) Error() string {
|
||||||
|
if e.Err != nil {
|
||||||
|
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying error.
|
||||||
|
func (e *DomainError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is checks if the target error matches this DomainError's code.
|
||||||
|
func (e *DomainError) Is(target error) bool {
|
||||||
|
var domainErr *DomainError
|
||||||
|
if errors.As(target, &domainErr) {
|
||||||
|
return e.Code == domainErr.Code
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new DomainError with the given code and message.
|
||||||
|
func New(code ErrorCode, message string) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap wraps an existing error with a DomainError.
|
||||||
|
func Wrap(code ErrorCode, message string, err error) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common domain errors
|
||||||
|
var (
|
||||||
|
// ErrNotFound indicates a requested resource was not found.
|
||||||
|
ErrNotFound = New(CodeNotFound, "resource not found")
|
||||||
|
|
||||||
|
// ErrInvalidInput indicates invalid input was provided.
|
||||||
|
ErrInvalidInput = New(CodeInvalidInput, "invalid input")
|
||||||
|
|
||||||
|
// ErrUnauthorized indicates the user is not authenticated.
|
||||||
|
ErrUnauthorized = New(CodeUnauthorized, "unauthorized")
|
||||||
|
|
||||||
|
// ErrForbidden indicates the user lacks permission.
|
||||||
|
ErrForbidden = New(CodeForbidden, "forbidden")
|
||||||
|
|
||||||
|
// ErrConflict indicates a resource conflict.
|
||||||
|
ErrConflict = New(CodeConflict, "resource conflict")
|
||||||
|
|
||||||
|
// ErrInternal indicates an internal server error.
|
||||||
|
ErrInternal = New(CodeInternal, "internal error")
|
||||||
|
|
||||||
|
// ErrSessionExpired indicates a game session has expired.
|
||||||
|
ErrSessionExpired = New(CodeSessionExpired, "session expired")
|
||||||
|
|
||||||
|
// ErrGameInProgress indicates a game is already in progress.
|
||||||
|
ErrGameInProgress = New(CodeGameInProgress, "game already in progress")
|
||||||
|
|
||||||
|
// ErrMaxAttemptsReached indicates the maximum attempts have been reached.
|
||||||
|
ErrMaxAttemptsReached = New(CodeMaxAttemptsReached, "maximum attempts reached")
|
||||||
|
)
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
// Package events provides domain event interfaces and types for the KnowFoolery application.
|
||||||
|
package events
|
||||||
|
|
||||||
|
// EventType represents the type of a domain event.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
// Game session events
|
||||||
|
const (
|
||||||
|
// GameSessionStarted is emitted when a new game session starts.
|
||||||
|
GameSessionStarted EventType = "game_session.started"
|
||||||
|
// GameSessionEnded is emitted when a game session ends.
|
||||||
|
GameSessionEnded EventType = "game_session.ended"
|
||||||
|
// GameSessionTimedOut is emitted when a game session times out.
|
||||||
|
GameSessionTimedOut EventType = "game_session.timed_out"
|
||||||
|
// AnswerSubmitted is emitted when a player submits an answer.
|
||||||
|
AnswerSubmitted EventType = "game_session.answer_submitted"
|
||||||
|
// HintRequested is emitted when a player requests a hint.
|
||||||
|
HintRequested EventType = "game_session.hint_requested"
|
||||||
|
// QuestionAnswered is emitted when a question is fully answered (correct or max attempts).
|
||||||
|
QuestionAnswered EventType = "game_session.question_answered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User events
|
||||||
|
const (
|
||||||
|
// UserRegistered is emitted when a new user registers.
|
||||||
|
UserRegistered EventType = "user.registered"
|
||||||
|
// UserEmailVerified is emitted when a user verifies their email.
|
||||||
|
UserEmailVerified EventType = "user.email_verified"
|
||||||
|
// UserDeleted is emitted when a user account is deleted.
|
||||||
|
UserDeleted EventType = "user.deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Question events
|
||||||
|
const (
|
||||||
|
// QuestionCreated is emitted when a new question is created.
|
||||||
|
QuestionCreated EventType = "question.created"
|
||||||
|
// QuestionUpdated is emitted when a question is updated.
|
||||||
|
QuestionUpdated EventType = "question.updated"
|
||||||
|
// QuestionDeleted is emitted when a question is deleted.
|
||||||
|
QuestionDeleted EventType = "question.deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leaderboard events
|
||||||
|
const (
|
||||||
|
// ScoreUpdated is emitted when a score is updated on the leaderboard.
|
||||||
|
ScoreUpdated EventType = "leaderboard.score_updated"
|
||||||
|
// LeaderboardRefreshed is emitted when the leaderboard is refreshed.
|
||||||
|
LeaderboardRefreshed EventType = "leaderboard.refreshed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the event type.
|
||||||
|
func (t EventType) String() string {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
// Package events provides domain event interfaces and types for the KnowFoolery application.
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents a domain event.
|
||||||
|
type Event interface {
|
||||||
|
// EventType returns the type of the event.
|
||||||
|
EventType() EventType
|
||||||
|
// OccurredAt returns when the event occurred.
|
||||||
|
OccurredAt() time.Time
|
||||||
|
// AggregateID returns the ID of the aggregate that produced the event.
|
||||||
|
AggregateID() string
|
||||||
|
// AggregateType returns the type of the aggregate.
|
||||||
|
AggregateType() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseEvent provides a base implementation of the Event interface.
|
||||||
|
type BaseEvent struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
AggrID string `json:"aggregate_id"`
|
||||||
|
AggrType string `json:"aggregate_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventType returns the type of the event.
|
||||||
|
func (e *BaseEvent) EventType() EventType {
|
||||||
|
return e.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// OccurredAt returns when the event occurred.
|
||||||
|
func (e *BaseEvent) OccurredAt() time.Time {
|
||||||
|
return e.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateID returns the ID of the aggregate that produced the event.
|
||||||
|
func (e *BaseEvent) AggregateID() string {
|
||||||
|
return e.AggrID
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateType returns the type of the aggregate.
|
||||||
|
func (e *BaseEvent) AggregateType() string {
|
||||||
|
return e.AggrType
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBaseEvent creates a new BaseEvent with the given parameters.
|
||||||
|
func NewBaseEvent(eventType EventType, aggregateID, aggregateType string) BaseEvent {
|
||||||
|
return BaseEvent{
|
||||||
|
Type: eventType,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
AggrID: aggregateID,
|
||||||
|
AggrType: aggregateType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventHandler handles domain events.
|
||||||
|
type EventHandler interface {
|
||||||
|
// Handle processes the given event.
|
||||||
|
Handle(ctx context.Context, event Event) error
|
||||||
|
// Handles returns the event types this handler can process.
|
||||||
|
Handles() []EventType
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventBus publishes and subscribes to domain events.
|
||||||
|
type EventBus interface {
|
||||||
|
// Publish publishes an event to all subscribers.
|
||||||
|
Publish(ctx context.Context, event Event) error
|
||||||
|
// Subscribe registers a handler for specific event types.
|
||||||
|
Subscribe(handler EventHandler) error
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
// Package types provides common domain types for the KnowFoolery application.
|
||||||
|
package types
|
||||||
|
|
||||||
|
// SessionStatus represents the status of a game session.
|
||||||
|
type SessionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionStatusCreated indicates a session has been created but not started.
|
||||||
|
SessionStatusCreated SessionStatus = "created"
|
||||||
|
// SessionStatusActive indicates a session is currently active.
|
||||||
|
SessionStatusActive SessionStatus = "active"
|
||||||
|
// SessionStatusCompleted indicates a session has been completed normally.
|
||||||
|
SessionStatusCompleted SessionStatus = "completed"
|
||||||
|
// SessionStatusTimedOut indicates a session has timed out.
|
||||||
|
SessionStatusTimedOut SessionStatus = "timed_out"
|
||||||
|
// SessionStatusAbandoned indicates a session has been abandoned.
|
||||||
|
SessionStatusAbandoned SessionStatus = "abandoned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the session status.
|
||||||
|
func (s SessionStatus) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTerminal checks if the session status is terminal (cannot transition).
|
||||||
|
func (s SessionStatus) IsTerminal() bool {
|
||||||
|
return s == SessionStatusCompleted || s == SessionStatusTimedOut || s == SessionStatusAbandoned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty represents the difficulty level of a question.
|
||||||
|
type Difficulty string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DifficultyEasy represents an easy question.
|
||||||
|
DifficultyEasy Difficulty = "easy"
|
||||||
|
// DifficultyMedium represents a medium difficulty question.
|
||||||
|
DifficultyMedium Difficulty = "medium"
|
||||||
|
// DifficultyHard represents a hard question.
|
||||||
|
DifficultyHard Difficulty = "hard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the difficulty.
|
||||||
|
func (d Difficulty) String() string {
|
||||||
|
return string(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRole represents a user's role in the system.
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RolePlayer represents a regular player.
|
||||||
|
RolePlayer UserRole = "player"
|
||||||
|
// RoleAdmin represents an administrator.
|
||||||
|
RoleAdmin UserRole = "admin"
|
||||||
|
// RoleModerator represents a moderator.
|
||||||
|
RoleModerator UserRole = "moderator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the user role.
|
||||||
|
func (r UserRole) String() string {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionType represents how a game session was completed.
|
||||||
|
type CompletionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CompletionNormal indicates normal completion.
|
||||||
|
CompletionNormal CompletionType = "normal"
|
||||||
|
// CompletionTimeout indicates the session timed out.
|
||||||
|
CompletionTimeout CompletionType = "timeout"
|
||||||
|
// CompletionAbandoned indicates the session was abandoned.
|
||||||
|
CompletionAbandoned CompletionType = "abandoned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the completion type.
|
||||||
|
func (c CompletionType) String() string {
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
// Package types provides common domain types for the KnowFoolery application.
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID represents a unique identifier.
|
||||||
|
type ID string
|
||||||
|
|
||||||
|
// NewID generates a new unique ID.
|
||||||
|
func NewID() ID {
|
||||||
|
return ID(uuid.New().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDFromString creates an ID from a string.
|
||||||
|
func IDFromString(s string) ID {
|
||||||
|
return ID(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the ID.
|
||||||
|
func (id ID) String() string {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty checks if the ID is empty.
|
||||||
|
func (id ID) IsEmpty() bool {
|
||||||
|
return id == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid checks if the ID is a valid UUID.
|
||||||
|
func (id ID) IsValid() bool {
|
||||||
|
if id.IsEmpty() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := uuid.Parse(string(id))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionID represents a game session identifier.
|
||||||
|
type SessionID = ID
|
||||||
|
|
||||||
|
// UserID represents a user identifier.
|
||||||
|
type UserID = ID
|
||||||
|
|
||||||
|
// QuestionID represents a question identifier.
|
||||||
|
type QuestionID = ID
|
||||||
|
|
||||||
|
// ThemeID represents a theme identifier.
|
||||||
|
type ThemeID = ID
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
// Package types provides common domain types for the KnowFoolery application.
|
||||||
|
package types
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultPageSize is the default number of items per page.
|
||||||
|
DefaultPageSize = 20
|
||||||
|
// MaxPageSize is the maximum number of items per page.
|
||||||
|
MaxPageSize = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pagination represents pagination parameters.
|
||||||
|
type Pagination struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPagination creates a new Pagination with default values.
|
||||||
|
func NewPagination() Pagination {
|
||||||
|
return Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: DefaultPageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset returns the offset for database queries.
|
||||||
|
func (p Pagination) Offset() int {
|
||||||
|
if p.Page < 1 {
|
||||||
|
p.Page = 1
|
||||||
|
}
|
||||||
|
return (p.Page - 1) * p.Limit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit returns the limit for database queries.
|
||||||
|
func (p Pagination) Limit() int {
|
||||||
|
if p.PageSize < 1 {
|
||||||
|
return DefaultPageSize
|
||||||
|
}
|
||||||
|
if p.PageSize > MaxPageSize {
|
||||||
|
return MaxPageSize
|
||||||
|
}
|
||||||
|
return p.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize ensures pagination values are within valid ranges.
|
||||||
|
func (p *Pagination) Normalize() {
|
||||||
|
if p.Page < 1 {
|
||||||
|
p.Page = 1
|
||||||
|
}
|
||||||
|
if p.PageSize < 1 {
|
||||||
|
p.PageSize = DefaultPageSize
|
||||||
|
}
|
||||||
|
if p.PageSize > MaxPageSize {
|
||||||
|
p.PageSize = MaxPageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginatedResult represents a paginated result set.
|
||||||
|
type PaginatedResult[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaginatedResult creates a new PaginatedResult.
|
||||||
|
func NewPaginatedResult[T any](items []T, totalCount int64, pagination Pagination) PaginatedResult[T] {
|
||||||
|
totalPages := int(totalCount) / pagination.Limit()
|
||||||
|
if int(totalCount)%pagination.Limit() > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
return PaginatedResult[T]{
|
||||||
|
Items: items,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: pagination.Page,
|
||||||
|
PageSize: pagination.Limit(),
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNextPage checks if there is a next page.
|
||||||
|
func (r PaginatedResult[T]) HasNextPage() bool {
|
||||||
|
return r.Page < r.TotalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPreviousPage checks if there is a previous page.
|
||||||
|
func (r PaginatedResult[T]) HasPreviousPage() bool {
|
||||||
|
return r.Page > 1
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
// Package valueobjects provides domain value objects for the KnowFoolery application.
|
||||||
|
package valueobjects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"knowfoolery/backend/shared/domain/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MinPlayerNameLength is the minimum length of a player name.
|
||||||
|
MinPlayerNameLength = 2
|
||||||
|
// MaxPlayerNameLength is the maximum length of a player name.
|
||||||
|
MaxPlayerNameLength = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// playerNamePattern matches alphanumeric characters, spaces, hyphens, underscores, and dots.
|
||||||
|
var playerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.]+$`)
|
||||||
|
|
||||||
|
// PlayerName represents a validated player name.
|
||||||
|
type PlayerName struct {
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayerName creates a new PlayerName with validation.
|
||||||
|
func NewPlayerName(name string) (PlayerName, error) {
|
||||||
|
// Trim whitespace
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
|
// Validate length
|
||||||
|
if len(name) < MinPlayerNameLength {
|
||||||
|
return PlayerName{}, errors.Wrap(
|
||||||
|
errors.CodeInvalidPlayerName,
|
||||||
|
"player name too short",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(name) > MaxPlayerNameLength {
|
||||||
|
return PlayerName{}, errors.Wrap(
|
||||||
|
errors.CodeInvalidPlayerName,
|
||||||
|
"player name too long",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate characters
|
||||||
|
if !playerNamePattern.MatchString(name) {
|
||||||
|
return PlayerName{}, errors.Wrap(
|
||||||
|
errors.CodeInvalidPlayerName,
|
||||||
|
"player name contains invalid characters",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize multiple spaces to single space
|
||||||
|
spaceRegex := regexp.MustCompile(`\s+`)
|
||||||
|
name = spaceRegex.ReplaceAllString(name, " ")
|
||||||
|
|
||||||
|
return PlayerName{value: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the player name.
|
||||||
|
func (p PlayerName) String() string {
|
||||||
|
return p.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equals checks if two player names are equal.
|
||||||
|
func (p PlayerName) Equals(other PlayerName) bool {
|
||||||
|
return strings.EqualFold(p.value, other.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty checks if the player name is empty.
|
||||||
|
func (p PlayerName) IsEmpty() bool {
|
||||||
|
return p.value == ""
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
// Package valueobjects provides domain value objects for the KnowFoolery application.
|
||||||
|
package valueobjects
|
||||||
|
|
||||||
|
// Scoring constants
|
||||||
|
const (
|
||||||
|
// MaxScorePerQuestion is the maximum score for a correct answer without hint.
|
||||||
|
MaxScorePerQuestion = 2
|
||||||
|
// ScoreWithHint is the score for a correct answer with hint.
|
||||||
|
ScoreWithHint = 1
|
||||||
|
// ScoreIncorrect is the score for an incorrect answer.
|
||||||
|
ScoreIncorrect = 0
|
||||||
|
// MaxAttempts is the maximum number of attempts per question.
|
||||||
|
MaxAttempts = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Score represents a game score value.
|
||||||
|
type Score struct {
|
||||||
|
value int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScore creates a new Score with validation.
|
||||||
|
func NewScore(value int) Score {
|
||||||
|
if value < 0 {
|
||||||
|
value = 0
|
||||||
|
}
|
||||||
|
return Score{value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero returns a zero score.
|
||||||
|
func Zero() Score {
|
||||||
|
return Score{value: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the score value.
|
||||||
|
func (s Score) Value() int {
|
||||||
|
return s.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds points to the score and returns a new Score.
|
||||||
|
func (s Score) Add(points int) Score {
|
||||||
|
newValue := s.value + points
|
||||||
|
if newValue < 0 {
|
||||||
|
newValue = 0
|
||||||
|
}
|
||||||
|
return Score{value: newValue}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateQuestionScore calculates the score for a question based on correctness and hint usage.
|
||||||
|
func CalculateQuestionScore(isCorrect bool, usedHint bool) int {
|
||||||
|
if !isCorrect {
|
||||||
|
return ScoreIncorrect
|
||||||
|
}
|
||||||
|
if usedHint {
|
||||||
|
return ScoreWithHint
|
||||||
|
}
|
||||||
|
return MaxScorePerQuestion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt represents an attempt at answering a question.
|
||||||
|
type Attempt struct {
|
||||||
|
Number int
|
||||||
|
Answer string
|
||||||
|
Correct bool
|
||||||
|
UsedHint bool
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAttempt creates a new Attempt.
|
||||||
|
func NewAttempt(number int, answer string, correct bool, usedHint bool) Attempt {
|
||||||
|
return Attempt{
|
||||||
|
Number: number,
|
||||||
|
Answer: answer,
|
||||||
|
Correct: correct,
|
||||||
|
UsedHint: usedHint,
|
||||||
|
Score: CalculateQuestionScore(correct, usedHint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRetry checks if another attempt is allowed.
|
||||||
|
func CanRetry(attemptNumber int) bool {
|
||||||
|
return attemptNumber < MaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemainingAttempts returns the number of remaining attempts.
|
||||||
|
func RemainingAttempts(attemptNumber int) int {
|
||||||
|
remaining := MaxAttempts - attemptNumber
|
||||||
|
if remaining < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
@ -1,3 +1,37 @@
|
|||||||
module knowfoolery/backend/shared
|
module knowfoolery/backend/shared
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/prometheus/client_golang v1.20.5
|
||||||
|
github.com/rs/zerolog v1.33.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.55.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.34.0 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||||
|
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@ -0,0 +1,157 @@
|
|||||||
|
// Package rbac provides role-based access control for the KnowFoolery application.
|
||||||
|
package rbac
|
||||||
|
|
||||||
|
// Role represents a user role.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// Predefined roles
|
||||||
|
const (
|
||||||
|
RolePlayer Role = "player"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
RoleModerator Role = "moderator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permission represents a permission in the system.
|
||||||
|
type Permission string
|
||||||
|
|
||||||
|
// Game permissions
|
||||||
|
const (
|
||||||
|
PermissionPlayGame Permission = "game:play"
|
||||||
|
PermissionViewGame Permission = "game:view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Question permissions
|
||||||
|
const (
|
||||||
|
PermissionViewQuestion Permission = "question:view"
|
||||||
|
PermissionCreateQuestion Permission = "question:create"
|
||||||
|
PermissionUpdateQuestion Permission = "question:update"
|
||||||
|
PermissionDeleteQuestion Permission = "question:delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User permissions
|
||||||
|
const (
|
||||||
|
PermissionViewOwnProfile Permission = "user:view:own"
|
||||||
|
PermissionUpdateOwnProfile Permission = "user:update:own"
|
||||||
|
PermissionDeleteOwnAccount Permission = "user:delete:own"
|
||||||
|
PermissionViewUsers Permission = "user:view:all"
|
||||||
|
PermissionManageUsers Permission = "user:manage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leaderboard permissions
|
||||||
|
const (
|
||||||
|
PermissionViewLeaderboard Permission = "leaderboard:view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin permissions
|
||||||
|
const (
|
||||||
|
PermissionViewAuditLog Permission = "audit:view"
|
||||||
|
PermissionViewDashboard Permission = "dashboard:view"
|
||||||
|
PermissionManageSystem Permission = "system:manage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rolePermissions maps roles to their permissions.
|
||||||
|
var rolePermissions = map[Role][]Permission{
|
||||||
|
RolePlayer: {
|
||||||
|
PermissionPlayGame,
|
||||||
|
PermissionViewGame,
|
||||||
|
PermissionViewQuestion,
|
||||||
|
PermissionViewLeaderboard,
|
||||||
|
PermissionViewOwnProfile,
|
||||||
|
PermissionUpdateOwnProfile,
|
||||||
|
PermissionDeleteOwnAccount,
|
||||||
|
},
|
||||||
|
RoleModerator: {
|
||||||
|
PermissionPlayGame,
|
||||||
|
PermissionViewGame,
|
||||||
|
PermissionViewQuestion,
|
||||||
|
PermissionCreateQuestion,
|
||||||
|
PermissionUpdateQuestion,
|
||||||
|
PermissionViewLeaderboard,
|
||||||
|
PermissionViewOwnProfile,
|
||||||
|
PermissionUpdateOwnProfile,
|
||||||
|
PermissionDeleteOwnAccount,
|
||||||
|
PermissionViewUsers,
|
||||||
|
},
|
||||||
|
RoleAdmin: {
|
||||||
|
PermissionPlayGame,
|
||||||
|
PermissionViewGame,
|
||||||
|
PermissionViewQuestion,
|
||||||
|
PermissionCreateQuestion,
|
||||||
|
PermissionUpdateQuestion,
|
||||||
|
PermissionDeleteQuestion,
|
||||||
|
PermissionViewLeaderboard,
|
||||||
|
PermissionViewOwnProfile,
|
||||||
|
PermissionUpdateOwnProfile,
|
||||||
|
PermissionDeleteOwnAccount,
|
||||||
|
PermissionViewUsers,
|
||||||
|
PermissionManageUsers,
|
||||||
|
PermissionViewAuditLog,
|
||||||
|
PermissionViewDashboard,
|
||||||
|
PermissionManageSystem,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission checks if a role has a specific permission.
|
||||||
|
func HasPermission(role Role, permission Permission) bool {
|
||||||
|
permissions, ok := rolePermissions[role]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range permissions {
|
||||||
|
if p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAnyPermission checks if a role has any of the specified permissions.
|
||||||
|
func HasAnyPermission(role Role, permissions ...Permission) bool {
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if HasPermission(role, permission) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAllPermissions checks if a role has all of the specified permissions.
|
||||||
|
func HasAllPermissions(role Role, permissions ...Permission) bool {
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if !HasPermission(role, permission) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasPermission checks if a user with the given roles has a specific permission.
|
||||||
|
func UserHasPermission(roles []string, permission Permission) bool {
|
||||||
|
for _, roleStr := range roles {
|
||||||
|
if HasPermission(Role(roleStr), permission) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissions returns all permissions for a role.
|
||||||
|
func GetPermissions(role Role) []Permission {
|
||||||
|
permissions, ok := rolePermissions[role]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy to prevent modification
|
||||||
|
result := make([]Permission, len(permissions))
|
||||||
|
copy(result, permissions)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidRole checks if a role string is a valid role.
|
||||||
|
func IsValidRole(roleStr string) bool {
|
||||||
|
role := Role(roleStr)
|
||||||
|
_, ok := rolePermissions[role]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
// Package zitadel provides Zitadel authentication client for the KnowFoolery application.
|
||||||
|
package zitadel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for the Zitadel client.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string
|
||||||
|
ProjectID string
|
||||||
|
AdminToken string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client provides access to Zitadel authentication services.
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
httpClient *http.Client
|
||||||
|
jwksCache *JWKSCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWKSCache caches the JSON Web Key Set for token validation.
|
||||||
|
type JWKSCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
keys map[string]interface{}
|
||||||
|
expiry time.Time
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWKSCache creates a new JWKS cache.
|
||||||
|
func NewJWKSCache(cacheDuration time.Duration) *JWKSCache {
|
||||||
|
return &JWKSCache{
|
||||||
|
keys: make(map[string]interface{}),
|
||||||
|
duration: cacheDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Zitadel client.
|
||||||
|
func NewClient(config Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
},
|
||||||
|
jwksCache: NewJWKSCache(5 * time.Minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthClaims represents the claims extracted from a validated JWT.
|
||||||
|
type AuthClaims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Roles []string `json:"urn:zitadel:iam:org:project:roles"`
|
||||||
|
Audience []string `json:"aud"`
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
MFAVerified bool `json:"amr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse represents a token response from Zitadel.
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo represents user information from Zitadel.
|
||||||
|
type UserInfo struct {
|
||||||
|
ID string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Verified bool `json:"email_verified"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the claims.
|
||||||
|
// This is a placeholder implementation that should be replaced with actual JWT validation.
|
||||||
|
func (c *Client) ValidateToken(ctx context.Context, token string) (*AuthClaims, error) {
|
||||||
|
// TODO: Implement actual JWT validation with JWKS
|
||||||
|
// This is a placeholder that should:
|
||||||
|
// 1. Fetch JWKS from Zitadel
|
||||||
|
// 2. Parse and validate the JWT
|
||||||
|
// 3. Verify signature, expiration, issuer, audience
|
||||||
|
// 4. Return the claims
|
||||||
|
return nil, fmt.Errorf("not implemented: token validation requires JWKS integration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes an access token using a refresh token.
|
||||||
|
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
||||||
|
// TODO: Implement token refresh
|
||||||
|
return nil, fmt.Errorf("not implemented: token refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo retrieves user information using an access token.
|
||||||
|
func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
|
||||||
|
url := fmt.Sprintf("%s/oidc/v1/userinfo", c.config.BaseURL)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to get user info: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo UserInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode user info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken revokes a token.
|
||||||
|
func (c *Client) RevokeToken(ctx context.Context, token string) error {
|
||||||
|
// TODO: Implement token revocation
|
||||||
|
return fmt.Errorf("not implemented: token revocation")
|
||||||
|
}
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
// Package zitadel provides Zitadel authentication client for the KnowFoolery application.
|
||||||
|
package zitadel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextKey represents a context key for authentication values.
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ContextKeyUserID is the context key for the user ID.
|
||||||
|
ContextKeyUserID ContextKey = "user_id"
|
||||||
|
// ContextKeyUserEmail is the context key for the user email.
|
||||||
|
ContextKeyUserEmail ContextKey = "user_email"
|
||||||
|
// ContextKeyUserName is the context key for the user name.
|
||||||
|
ContextKeyUserName ContextKey = "user_name"
|
||||||
|
// ContextKeyUserRoles is the context key for the user roles.
|
||||||
|
ContextKeyUserRoles ContextKey = "user_roles"
|
||||||
|
// ContextKeyMFAVerified is the context key for MFA verification status.
|
||||||
|
ContextKeyMFAVerified ContextKey = "mfa_verified"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTMiddlewareConfig holds configuration for the JWT middleware.
|
||||||
|
type JWTMiddlewareConfig struct {
|
||||||
|
Client *Client
|
||||||
|
Issuer string
|
||||||
|
Audience string
|
||||||
|
RequiredClaims []string
|
||||||
|
AdminEndpoints []string
|
||||||
|
SkipPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTMiddleware creates a Fiber middleware for JWT validation.
|
||||||
|
func JWTMiddleware(config JWTMiddlewareConfig) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
// Check if path should be skipped
|
||||||
|
path := c.Path()
|
||||||
|
for _, skipPath := range config.SkipPaths {
|
||||||
|
if strings.HasPrefix(path, skipPath) {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token from Authorization header
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": true,
|
||||||
|
"message": "Authorization header required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": true,
|
||||||
|
"message": "Invalid authorization header format",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
ctx := c.Context()
|
||||||
|
claims, err := config.Client.ValidateToken(ctx, tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": true,
|
||||||
|
"message": "Invalid token",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user context
|
||||||
|
c.Locals(string(ContextKeyUserID), claims.Subject)
|
||||||
|
c.Locals(string(ContextKeyUserEmail), claims.Email)
|
||||||
|
c.Locals(string(ContextKeyUserName), claims.Name)
|
||||||
|
c.Locals(string(ContextKeyUserRoles), claims.Roles)
|
||||||
|
c.Locals(string(ContextKeyMFAVerified), claims.MFAVerified)
|
||||||
|
|
||||||
|
// Check admin access for admin endpoints
|
||||||
|
if isAdminEndpoint(path, config.AdminEndpoints) {
|
||||||
|
if !hasAdminRole(claims.Roles) {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"error": true,
|
||||||
|
"message": "Admin access required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !claims.MFAVerified {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"error": true,
|
||||||
|
"message": "MFA verification required for admin access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAdminEndpoint checks if the given path is an admin endpoint.
|
||||||
|
func isAdminEndpoint(path string, adminEndpoints []string) bool {
|
||||||
|
for _, adminPath := range adminEndpoints {
|
||||||
|
if strings.HasPrefix(path, adminPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAdminRole checks if the user has the admin role.
|
||||||
|
func hasAdminRole(roles []string) bool {
|
||||||
|
for _, role := range roles {
|
||||||
|
if role == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserID extracts the user ID from the Fiber context.
|
||||||
|
func GetUserID(c fiber.Ctx) string {
|
||||||
|
if userID := c.Locals(string(ContextKeyUserID)); userID != nil {
|
||||||
|
return userID.(string)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserEmail extracts the user email from the Fiber context.
|
||||||
|
func GetUserEmail(c fiber.Ctx) string {
|
||||||
|
if email := c.Locals(string(ContextKeyUserEmail)); email != nil {
|
||||||
|
return email.(string)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRoles extracts the user roles from the Fiber context.
|
||||||
|
func GetUserRoles(c fiber.Ctx) []string {
|
||||||
|
if roles := c.Locals(string(ContextKeyUserRoles)); roles != nil {
|
||||||
|
return roles.([]string)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMFAVerified checks if MFA has been verified for the current user.
|
||||||
|
func IsMFAVerified(c fiber.Ctx) bool {
|
||||||
|
if verified := c.Locals(string(ContextKeyMFAVerified)); verified != nil {
|
||||||
|
return verified.(bool)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFromContext retrieves user information from a standard context.
|
||||||
|
func GetUserFromContext(ctx context.Context) (userID, email, name string, roles []string, ok bool) {
|
||||||
|
userID, _ = ctx.Value(ContextKeyUserID).(string)
|
||||||
|
email, _ = ctx.Value(ContextKeyUserEmail).(string)
|
||||||
|
name, _ = ctx.Value(ContextKeyUserName).(string)
|
||||||
|
roles, _ = ctx.Value(ContextKeyUserRoles).([]string)
|
||||||
|
ok = userID != ""
|
||||||
|
return
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
// Package postgres provides PostgreSQL database client for the KnowFoolery application.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for the PostgreSQL client.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
SSLMode string
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
ConnMaxIdleTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration for development.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "postgres",
|
||||||
|
Password: "postgres",
|
||||||
|
Database: "knowfoolery",
|
||||||
|
SSLMode: "disable",
|
||||||
|
MaxOpenConns: 25,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
ConnMaxLifetime: 5 * time.Minute,
|
||||||
|
ConnMaxIdleTime: 1 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSN returns the PostgreSQL connection string.
|
||||||
|
func (c Config) DSN() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
c.Host, c.Port, c.User, c.Password, c.Database, c.SSLMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL returns the PostgreSQL connection URL.
|
||||||
|
func (c Config) URL() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"postgresql://%s:%s@%s:%d/%s?sslmode=%s",
|
||||||
|
c.User, c.Password, c.Host, c.Port, c.Database, c.SSLMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client wraps database operations.
|
||||||
|
// This is a placeholder that should be implemented with actual database client.
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new PostgreSQL client.
|
||||||
|
// Note: Actual implementation should use Ent client from each service.
|
||||||
|
func NewClient(config Config) (*Client, error) {
|
||||||
|
// This is a placeholder. The actual Ent client should be created
|
||||||
|
// in each service's infrastructure layer.
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
// Placeholder for closing database connection
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping checks if the database connection is alive.
|
||||||
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
|
// Placeholder for ping implementation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck performs a health check on the database.
|
||||||
|
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return c.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv creates a Config from environment variables.
|
||||||
|
// This is a placeholder that should be implemented with actual env parsing.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
// TODO: Implement environment variable parsing
|
||||||
|
// Should use os.Getenv or a configuration library
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
// Package redis provides Redis client for the KnowFoolery application.
|
||||||
|
package redis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for the Redis client.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
PoolSize int
|
||||||
|
MinIdleConns int
|
||||||
|
DialTimeout time.Duration
|
||||||
|
ReadTimeout time.Duration
|
||||||
|
WriteTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration for development.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 6379,
|
||||||
|
Password: "",
|
||||||
|
DB: 0,
|
||||||
|
PoolSize: 10,
|
||||||
|
MinIdleConns: 5,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 3 * time.Second,
|
||||||
|
WriteTimeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the Redis server address.
|
||||||
|
func (c Config) Addr() string {
|
||||||
|
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client wraps Redis operations.
|
||||||
|
// This is a placeholder that should be replaced with an actual Redis client.
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Redis client.
|
||||||
|
// Note: Actual implementation should use go-redis/redis.
|
||||||
|
func NewClient(config Config) (*Client, error) {
|
||||||
|
// This is a placeholder. The actual Redis client should be created
|
||||||
|
// using github.com/go-redis/redis/v9
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
// Placeholder for closing Redis connection
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping checks if the Redis connection is alive.
|
||||||
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
|
// Placeholder for ping implementation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck performs a health check on Redis.
|
||||||
|
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return c.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a key-value pair with expiration.
|
||||||
|
func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
// Placeholder for set implementation
|
||||||
|
return fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value by key.
|
||||||
|
func (c *Client) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
// Placeholder for get implementation
|
||||||
|
return "", fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a key.
|
||||||
|
func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
||||||
|
// Placeholder for delete implementation
|
||||||
|
return fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a key exists.
|
||||||
|
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
// Placeholder for exists implementation
|
||||||
|
return false, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incr increments a counter.
|
||||||
|
func (c *Client) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
|
// Placeholder for incr implementation
|
||||||
|
return 0, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire sets expiration on a key.
|
||||||
|
func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
// Placeholder for expire implementation
|
||||||
|
return fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv creates a Config from environment variables.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
// TODO: Implement environment variable parsing
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
// Package logging provides structured logging for the KnowFoolery application.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for the logger.
|
||||||
|
type Config struct {
|
||||||
|
Level string
|
||||||
|
Environment string
|
||||||
|
ServiceName string
|
||||||
|
Version string
|
||||||
|
Output io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Level: "info",
|
||||||
|
Environment: "development",
|
||||||
|
ServiceName: "knowfoolery",
|
||||||
|
Version: "0.0.0",
|
||||||
|
Output: os.Stdout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger wraps zerolog.Logger with application-specific methods.
|
||||||
|
type Logger struct {
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a new Logger.
|
||||||
|
func NewLogger(config Config) *Logger {
|
||||||
|
// Parse log level
|
||||||
|
level, err := zerolog.ParseLevel(config.Level)
|
||||||
|
if err != nil {
|
||||||
|
level = zerolog.InfoLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set global level
|
||||||
|
zerolog.SetGlobalLevel(level)
|
||||||
|
zerolog.TimeFieldFormat = time.RFC3339Nano
|
||||||
|
|
||||||
|
var logger zerolog.Logger
|
||||||
|
output := config.Output
|
||||||
|
if output == nil {
|
||||||
|
output = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Environment == "development" {
|
||||||
|
// Human-readable console output for development
|
||||||
|
logger = zerolog.New(zerolog.ConsoleWriter{
|
||||||
|
Out: output,
|
||||||
|
TimeFormat: "15:04:05",
|
||||||
|
}).With().Timestamp().Logger()
|
||||||
|
} else {
|
||||||
|
// JSON output for production
|
||||||
|
logger = zerolog.New(output).With().Timestamp().Logger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add service metadata
|
||||||
|
logger = logger.With().
|
||||||
|
Str("service", config.ServiceName).
|
||||||
|
Str("version", config.Version).
|
||||||
|
Str("environment", config.Environment).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
return &Logger{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs a debug message.
|
||||||
|
func (l *Logger) Debug(msg string) {
|
||||||
|
l.logger.Debug().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs an info message.
|
||||||
|
func (l *Logger) Info(msg string) {
|
||||||
|
l.logger.Info().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs a warning message.
|
||||||
|
func (l *Logger) Warn(msg string) {
|
||||||
|
l.logger.Warn().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs an error message.
|
||||||
|
func (l *Logger) Error(msg string) {
|
||||||
|
l.logger.Error().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs a fatal message and exits.
|
||||||
|
func (l *Logger) Fatal(msg string) {
|
||||||
|
l.logger.Fatal().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithError adds an error to the log entry.
|
||||||
|
func (l *Logger) WithError(err error) *Logger {
|
||||||
|
return &Logger{
|
||||||
|
logger: l.logger.With().Err(err).Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithField adds a field to the log entry.
|
||||||
|
func (l *Logger) WithField(key string, value interface{}) *Logger {
|
||||||
|
return &Logger{
|
||||||
|
logger: l.logger.With().Interface(key, value).Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFields adds multiple fields to the log entry.
|
||||||
|
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
|
||||||
|
ctx := l.logger.With()
|
||||||
|
for k, v := range fields {
|
||||||
|
ctx = ctx.Interface(k, v)
|
||||||
|
}
|
||||||
|
return &Logger{
|
||||||
|
logger: ctx.Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameEvent logs a game-related event.
|
||||||
|
func (l *Logger) GameEvent(event string, gameSessionID, userID string, properties map[string]interface{}) {
|
||||||
|
l.logger.Info().
|
||||||
|
Str("event_type", "game").
|
||||||
|
Str("event", event).
|
||||||
|
Str("game_session_id", gameSessionID).
|
||||||
|
Str("user_id", userID).
|
||||||
|
Fields(properties).
|
||||||
|
Msg("Game event occurred")
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIRequest logs an API request.
|
||||||
|
func (l *Logger) APIRequest(method, path string, statusCode int, duration time.Duration, userID string) {
|
||||||
|
l.logger.Info().
|
||||||
|
Str("event_type", "api_request").
|
||||||
|
Str("method", method).
|
||||||
|
Str("path", path).
|
||||||
|
Int("status_code", statusCode).
|
||||||
|
Dur("duration_ms", duration).
|
||||||
|
Str("user_id", userID).
|
||||||
|
Msg("API request processed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseOperation logs a database operation.
|
||||||
|
func (l *Logger) DatabaseOperation(operation, table string, duration time.Duration, rowsAffected int64) {
|
||||||
|
l.logger.Debug().
|
||||||
|
Str("event_type", "database").
|
||||||
|
Str("operation", operation).
|
||||||
|
Str("table", table).
|
||||||
|
Dur("duration_ms", duration).
|
||||||
|
Int64("rows_affected", rowsAffected).
|
||||||
|
Msg("Database operation completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationEvent logs an authentication event.
|
||||||
|
func (l *Logger) AuthenticationEvent(event, userID, userType string, success bool, details map[string]string) {
|
||||||
|
logEntry := l.logger.Info()
|
||||||
|
if !success {
|
||||||
|
logEntry = l.logger.Warn()
|
||||||
|
}
|
||||||
|
|
||||||
|
logEntry.
|
||||||
|
Str("event_type", "authentication").
|
||||||
|
Str("event", event).
|
||||||
|
Str("user_id", userID).
|
||||||
|
Str("user_type", userType).
|
||||||
|
Bool("success", success).
|
||||||
|
Fields(details).
|
||||||
|
Msg("Authentication event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityEvent logs a security event.
|
||||||
|
func (l *Logger) SecurityEvent(event, userID, ipAddress string, severity string, details map[string]interface{}) {
|
||||||
|
l.logger.Warn().
|
||||||
|
Str("event_type", "security").
|
||||||
|
Str("event", event).
|
||||||
|
Str("user_id", userID).
|
||||||
|
Str("ip_address", ipAddress).
|
||||||
|
Str("severity", severity).
|
||||||
|
Fields(details).
|
||||||
|
Msg("Security event detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zerolog returns the underlying zerolog.Logger for advanced usage.
|
||||||
|
func (l *Logger) Zerolog() zerolog.Logger {
|
||||||
|
return l.logger
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
// Package metrics provides Prometheus metrics for the KnowFoolery application.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for metrics.
|
||||||
|
type Config struct {
|
||||||
|
ServiceName string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
ServiceName: "knowfoolery",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics holds all Prometheus metrics for the application.
|
||||||
|
type Metrics struct {
|
||||||
|
config Config
|
||||||
|
|
||||||
|
// HTTP metrics
|
||||||
|
HTTPRequestsTotal *prometheus.CounterVec
|
||||||
|
HTTPRequestDuration *prometheus.HistogramVec
|
||||||
|
|
||||||
|
// Database metrics
|
||||||
|
DBConnectionsActive *prometheus.GaugeVec
|
||||||
|
DBQueryDuration *prometheus.HistogramVec
|
||||||
|
DBErrors *prometheus.CounterVec
|
||||||
|
|
||||||
|
// Cache metrics
|
||||||
|
CacheOperations *prometheus.CounterVec
|
||||||
|
CacheKeyCount *prometheus.GaugeVec
|
||||||
|
|
||||||
|
// Authentication metrics
|
||||||
|
AuthAttempts *prometheus.CounterVec
|
||||||
|
TokenOperations *prometheus.CounterVec
|
||||||
|
|
||||||
|
// Game metrics
|
||||||
|
GamesStarted *prometheus.CounterVec
|
||||||
|
GamesCompleted *prometheus.CounterVec
|
||||||
|
SessionDuration *prometheus.HistogramVec
|
||||||
|
QuestionsAsked *prometheus.CounterVec
|
||||||
|
AnswersSubmitted *prometheus.CounterVec
|
||||||
|
HintsRequested *prometheus.CounterVec
|
||||||
|
ScoreDistribution *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetrics creates a new Metrics instance with all metrics registered.
|
||||||
|
func NewMetrics(config Config) *Metrics {
|
||||||
|
m := &Metrics{config: config}
|
||||||
|
|
||||||
|
// HTTP metrics
|
||||||
|
m.HTTPRequestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "http_requests_total",
|
||||||
|
Help: "Total number of HTTP requests",
|
||||||
|
},
|
||||||
|
[]string{"method", "endpoint", "status_code", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.HTTPRequestDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "http_request_duration_seconds",
|
||||||
|
Help: "HTTP request duration",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
},
|
||||||
|
[]string{"method", "endpoint", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database metrics
|
||||||
|
m.DBConnectionsActive = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "db_connections_active",
|
||||||
|
Help: "Number of active database connections",
|
||||||
|
},
|
||||||
|
[]string{"database", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.DBQueryDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "db_query_duration_seconds",
|
||||||
|
Help: "Database query duration",
|
||||||
|
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0},
|
||||||
|
},
|
||||||
|
[]string{"query_type", "table", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.DBErrors = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "db_errors_total",
|
||||||
|
Help: "Total number of database errors",
|
||||||
|
},
|
||||||
|
[]string{"error_type", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache metrics
|
||||||
|
m.CacheOperations = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "cache_operations_total",
|
||||||
|
Help: "Total number of cache operations",
|
||||||
|
},
|
||||||
|
[]string{"operation", "result", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.CacheKeyCount = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "cache_keys_total",
|
||||||
|
Help: "Number of keys in cache",
|
||||||
|
},
|
||||||
|
[]string{"cache_type", "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authentication metrics
|
||||||
|
m.AuthAttempts = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "authentication_attempts_total",
|
||||||
|
Help: "Total authentication attempts",
|
||||||
|
},
|
||||||
|
[]string{"method", "result", "user_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.TokenOperations = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "token_operations_total",
|
||||||
|
Help: "JWT token operations",
|
||||||
|
},
|
||||||
|
[]string{"operation", "result"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game metrics
|
||||||
|
m.GamesStarted = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "games_started_total",
|
||||||
|
Help: "Total number of games started",
|
||||||
|
},
|
||||||
|
[]string{"player_type", "platform"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.GamesCompleted = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "games_completed_total",
|
||||||
|
Help: "Total number of games completed",
|
||||||
|
},
|
||||||
|
[]string{"completion_type", "platform"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.SessionDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "game_session_duration_seconds",
|
||||||
|
Help: "Duration of game sessions",
|
||||||
|
Buckets: []float64{60, 300, 600, 900, 1200, 1500, 1800},
|
||||||
|
},
|
||||||
|
[]string{"completion_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.QuestionsAsked = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "questions_asked_total",
|
||||||
|
Help: "Total number of questions asked",
|
||||||
|
},
|
||||||
|
[]string{"theme", "difficulty"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AnswersSubmitted = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "answers_submitted_total",
|
||||||
|
Help: "Total number of answers submitted",
|
||||||
|
},
|
||||||
|
[]string{"theme", "is_correct", "attempt_number", "used_hint"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.HintsRequested = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "hints_requested_total",
|
||||||
|
Help: "Total number of hints requested",
|
||||||
|
},
|
||||||
|
[]string{"theme", "question_difficulty"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.ScoreDistribution = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "game_scores",
|
||||||
|
Help: "Distribution of game scores",
|
||||||
|
Buckets: []float64{0, 5, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100},
|
||||||
|
},
|
||||||
|
[]string{"session_duration_bucket"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv creates a Config from environment variables.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
// TODO: Implement environment variable parsing
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
// Package tracing provides distributed tracing for the KnowFoolery application.
|
||||||
|
package tracing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration for the tracer.
|
||||||
|
type Config struct {
|
||||||
|
ServiceName string
|
||||||
|
ServiceVersion string
|
||||||
|
Environment string
|
||||||
|
JaegerEndpoint string
|
||||||
|
SampleRate float64
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
ServiceName: "knowfoolery",
|
||||||
|
ServiceVersion: "0.0.0",
|
||||||
|
Environment: "development",
|
||||||
|
JaegerEndpoint: "http://localhost:14268/api/traces",
|
||||||
|
SampleRate: 1.0, // Sample all traces in development
|
||||||
|
Enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracer provides distributed tracing functionality.
|
||||||
|
// This is a placeholder that should be implemented with OpenTelemetry.
|
||||||
|
type Tracer struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTracer creates a new Tracer.
|
||||||
|
// Note: Actual implementation should use OpenTelemetry SDK.
|
||||||
|
func NewTracer(config Config) (*Tracer, error) {
|
||||||
|
if !config.Enabled {
|
||||||
|
return &Tracer{config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Initialize OpenTelemetry tracer provider
|
||||||
|
// Example implementation:
|
||||||
|
// - Create Jaeger exporter
|
||||||
|
// - Create tracer provider with batching
|
||||||
|
// - Set global tracer provider
|
||||||
|
// - Configure resource with service info
|
||||||
|
|
||||||
|
return &Tracer{config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown gracefully shuts down the tracer.
|
||||||
|
func (t *Tracer) Shutdown(ctx context.Context) error {
|
||||||
|
if !t.config.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Shutdown tracer provider
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSpan starts a new span.
|
||||||
|
// This is a placeholder that should be implemented with OpenTelemetry.
|
||||||
|
func (t *Tracer) StartSpan(ctx context.Context, name string) (context.Context, Span) {
|
||||||
|
// TODO: Start actual OpenTelemetry span
|
||||||
|
return ctx, &noopSpan{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Span represents a tracing span.
|
||||||
|
type Span interface {
|
||||||
|
// End ends the span.
|
||||||
|
End()
|
||||||
|
// SetAttribute sets an attribute on the span.
|
||||||
|
SetAttribute(key string, value interface{})
|
||||||
|
// RecordError records an error on the span.
|
||||||
|
RecordError(err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noopSpan is a no-op implementation of Span.
|
||||||
|
type noopSpan struct{}
|
||||||
|
|
||||||
|
func (s *noopSpan) End() {}
|
||||||
|
func (s *noopSpan) SetAttribute(key string, value interface{}) {}
|
||||||
|
func (s *noopSpan) RecordError(err error) {}
|
||||||
|
|
||||||
|
// TraceServiceOperation traces a service operation.
|
||||||
|
func TraceServiceOperation(ctx context.Context, tracer *Tracer, serviceName, operation string, fn func(context.Context) error) error {
|
||||||
|
ctx, span := tracer.StartSpan(ctx, fmt.Sprintf("%s.%s", serviceName, operation))
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetAttribute("error", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceDatabaseOperation traces a database operation.
|
||||||
|
func TraceDatabaseOperation(ctx context.Context, tracer *Tracer, operation, table string, fn func(context.Context) error) error {
|
||||||
|
ctx, span := tracer.StartSpan(ctx, fmt.Sprintf("db.%s.%s", operation, table))
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttribute("db.operation", operation)
|
||||||
|
span.SetAttribute("db.table", table)
|
||||||
|
span.SetAttribute("db.system", "postgresql")
|
||||||
|
|
||||||
|
err := fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv creates a Config from environment variables.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
// TODO: Implement environment variable parsing
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
// Package security provides security utilities for the KnowFoolery application.
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SanitizeOptions configures sanitization behavior.
|
||||||
|
type SanitizeOptions struct {
|
||||||
|
TrimWhitespace bool
|
||||||
|
RemoveMultipleSpaces bool
|
||||||
|
HTMLEscape bool
|
||||||
|
MaxLength int
|
||||||
|
AllowedPattern *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSanitizeOptions returns default sanitization options.
|
||||||
|
func DefaultSanitizeOptions() SanitizeOptions {
|
||||||
|
return SanitizeOptions{
|
||||||
|
TrimWhitespace: true,
|
||||||
|
RemoveMultipleSpaces: true,
|
||||||
|
HTMLEscape: true,
|
||||||
|
MaxLength: 0, // No limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize sanitizes a string according to the given options.
|
||||||
|
func Sanitize(input string, opts SanitizeOptions) string {
|
||||||
|
result := input
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
if opts.TrimWhitespace {
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple consecutive spaces
|
||||||
|
if opts.RemoveMultipleSpaces {
|
||||||
|
spaceRegex := regexp.MustCompile(`\s+`)
|
||||||
|
result = spaceRegex.ReplaceAllString(result, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML escape
|
||||||
|
if opts.HTMLEscape {
|
||||||
|
result = html.EscapeString(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply maximum length
|
||||||
|
if opts.MaxLength > 0 && len(result) > opts.MaxLength {
|
||||||
|
result = result[:opts.MaxLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate against allowed pattern
|
||||||
|
if opts.AllowedPattern != nil && !opts.AllowedPattern.MatchString(result) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizePlayerName sanitizes a player name.
|
||||||
|
func SanitizePlayerName(input string) string {
|
||||||
|
opts := SanitizeOptions{
|
||||||
|
TrimWhitespace: true,
|
||||||
|
RemoveMultipleSpaces: true,
|
||||||
|
HTMLEscape: true,
|
||||||
|
MaxLength: 50,
|
||||||
|
AllowedPattern: regexp.MustCompile(`^[a-zA-Z0-9\s\-_.]+$`),
|
||||||
|
}
|
||||||
|
return Sanitize(input, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeAnswer sanitizes an answer submission.
|
||||||
|
func SanitizeAnswer(input string) string {
|
||||||
|
opts := SanitizeOptions{
|
||||||
|
TrimWhitespace: true,
|
||||||
|
RemoveMultipleSpaces: true,
|
||||||
|
HTMLEscape: true,
|
||||||
|
MaxLength: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Sanitize(input, opts)
|
||||||
|
|
||||||
|
// Normalize to lowercase for comparison
|
||||||
|
result = strings.ToLower(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeQuestionText sanitizes question text (admin input).
|
||||||
|
func SanitizeQuestionText(input string) string {
|
||||||
|
opts := SanitizeOptions{
|
||||||
|
TrimWhitespace: true,
|
||||||
|
RemoveMultipleSpaces: true,
|
||||||
|
HTMLEscape: true,
|
||||||
|
MaxLength: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Sanitize(input, opts)
|
||||||
|
|
||||||
|
// Remove potential script content
|
||||||
|
scriptRegex := regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`)
|
||||||
|
result = scriptRegex.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeTheme sanitizes a theme name.
|
||||||
|
func SanitizeTheme(input string) string {
|
||||||
|
opts := SanitizeOptions{
|
||||||
|
TrimWhitespace: true,
|
||||||
|
RemoveMultipleSpaces: true,
|
||||||
|
HTMLEscape: true,
|
||||||
|
MaxLength: 100,
|
||||||
|
AllowedPattern: regexp.MustCompile(`^[a-zA-Z0-9\s\-_]+$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Sanitize(input, opts)
|
||||||
|
|
||||||
|
// Title case
|
||||||
|
words := strings.Fields(result)
|
||||||
|
for i, word := range words {
|
||||||
|
if len(word) > 0 {
|
||||||
|
runes := []rune(word)
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
for j := 1; j < len(runes); j++ {
|
||||||
|
runes[j] = unicode.ToLower(runes[j])
|
||||||
|
}
|
||||||
|
words[i] = string(runes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(words, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHTMLTags removes all HTML tags from a string.
|
||||||
|
func RemoveHTMLTags(input string) string {
|
||||||
|
tagRegex := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
return tagRegex.ReplaceAllString(input, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsDangerousPatterns checks if input contains potentially dangerous patterns.
|
||||||
|
func ContainsDangerousPatterns(input string) bool {
|
||||||
|
dangerousPatterns := []string{
|
||||||
|
"javascript:",
|
||||||
|
"data:",
|
||||||
|
"vbscript:",
|
||||||
|
"<script",
|
||||||
|
"</script",
|
||||||
|
"onerror",
|
||||||
|
"onload",
|
||||||
|
"onclick",
|
||||||
|
"onmouseover",
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerInput := strings.ToLower(input)
|
||||||
|
for _, pattern := range dangerousPatterns {
|
||||||
|
if strings.Contains(lowerInput, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidEmail performs basic email validation.
|
||||||
|
func IsValidEmail(email string) bool {
|
||||||
|
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||||
|
return emailRegex.MatchString(email)
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
// Package httputil provides HTTP utility functions for the KnowFoolery application.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
||||||
|
"knowfoolery/backend/shared/domain/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorResponse represents a standard error response.
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorResponse creates a new ErrorResponse.
|
||||||
|
func NewErrorResponse(code, message, details string) ErrorResponse {
|
||||||
|
return ErrorResponse{
|
||||||
|
Error: true,
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendError sends an error response with the appropriate HTTP status code.
|
||||||
|
func SendError(c fiber.Ctx, err error) error {
|
||||||
|
statusCode, response := MapError(err)
|
||||||
|
return c.Status(statusCode).JSON(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapError maps a domain error to an HTTP status code and response.
|
||||||
|
func MapError(err error) (int, ErrorResponse) {
|
||||||
|
if err == nil {
|
||||||
|
return fiber.StatusInternalServerError, NewErrorResponse(
|
||||||
|
"INTERNAL",
|
||||||
|
"An unexpected error occurred",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainErr, ok := err.(*errors.DomainError)
|
||||||
|
if !ok {
|
||||||
|
return fiber.StatusInternalServerError, NewErrorResponse(
|
||||||
|
"INTERNAL",
|
||||||
|
"An unexpected error occurred",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := mapErrorCodeToStatus(domainErr.Code)
|
||||||
|
return statusCode, NewErrorResponse(
|
||||||
|
domainErr.Code.String(),
|
||||||
|
domainErr.Message,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapErrorCodeToStatus maps an error code to an HTTP status code.
|
||||||
|
func mapErrorCodeToStatus(code errors.ErrorCode) int {
|
||||||
|
switch code {
|
||||||
|
case errors.CodeNotFound, errors.CodeQuestionNotFound, errors.CodeUserNotFound:
|
||||||
|
return fiber.StatusNotFound
|
||||||
|
case errors.CodeInvalidInput, errors.CodeValidationFailed, errors.CodeInvalidPlayerName, errors.CodeInvalidAnswer:
|
||||||
|
return fiber.StatusBadRequest
|
||||||
|
case errors.CodeUnauthorized, errors.CodeInvalidToken, errors.CodeTokenExpired:
|
||||||
|
return fiber.StatusUnauthorized
|
||||||
|
case errors.CodeForbidden, errors.CodeMFARequired:
|
||||||
|
return fiber.StatusForbidden
|
||||||
|
case errors.CodeConflict, errors.CodeUserAlreadyExists, errors.CodeGameInProgress:
|
||||||
|
return fiber.StatusConflict
|
||||||
|
case errors.CodeRateLimitExceeded:
|
||||||
|
return fiber.StatusTooManyRequests
|
||||||
|
case errors.CodeSessionExpired, errors.CodeSessionNotActive:
|
||||||
|
return fiber.StatusGone
|
||||||
|
case errors.CodeMaxAttemptsReached:
|
||||||
|
return fiber.StatusUnprocessableEntity
|
||||||
|
default:
|
||||||
|
return fiber.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest sends a 400 Bad Request response.
|
||||||
|
func BadRequest(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(NewErrorResponse(
|
||||||
|
"BAD_REQUEST",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthorized sends a 401 Unauthorized response.
|
||||||
|
func Unauthorized(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(NewErrorResponse(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forbidden sends a 403 Forbidden response.
|
||||||
|
func Forbidden(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(NewErrorResponse(
|
||||||
|
"FORBIDDEN",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFound sends a 404 Not Found response.
|
||||||
|
func NotFound(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(NewErrorResponse(
|
||||||
|
"NOT_FOUND",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict sends a 409 Conflict response.
|
||||||
|
func Conflict(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(NewErrorResponse(
|
||||||
|
"CONFLICT",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TooManyRequests sends a 429 Too Many Requests response.
|
||||||
|
func TooManyRequests(c fiber.Ctx, message string, retryAfter int) error {
|
||||||
|
c.Set("Retry-After", string(rune(retryAfter)))
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(NewErrorResponse(
|
||||||
|
"RATE_LIMIT_EXCEEDED",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalError sends a 500 Internal Server Error response.
|
||||||
|
func InternalError(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(NewErrorResponse(
|
||||||
|
"INTERNAL",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
// Package httputil provides HTTP utility functions for the KnowFoolery application.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
||||||
|
"knowfoolery/backend/shared/domain/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaginationFromQuery extracts pagination parameters from query string.
|
||||||
|
func PaginationFromQuery(c fiber.Ctx) types.Pagination {
|
||||||
|
page := c.Query("page", "1")
|
||||||
|
pageSize := c.Query("page_size", strconv.Itoa(types.DefaultPageSize))
|
||||||
|
|
||||||
|
pageNum, err := strconv.Atoi(page)
|
||||||
|
if err != nil || pageNum < 1 {
|
||||||
|
pageNum = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSizeNum, err := strconv.Atoi(pageSize)
|
||||||
|
if err != nil || pageSizeNum < 1 {
|
||||||
|
pageSizeNum = types.DefaultPageSize
|
||||||
|
}
|
||||||
|
if pageSizeNum > types.MaxPageSize {
|
||||||
|
pageSizeNum = types.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.Pagination{
|
||||||
|
Page: pageNum,
|
||||||
|
PageSize: pageSizeNum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortingParams holds sorting parameters.
|
||||||
|
type SortingParams struct {
|
||||||
|
Field string
|
||||||
|
Direction string // "asc" or "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSorting returns default sorting parameters.
|
||||||
|
func DefaultSorting(defaultField string) SortingParams {
|
||||||
|
return SortingParams{
|
||||||
|
Field: defaultField,
|
||||||
|
Direction: "asc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortingFromQuery extracts sorting parameters from query string.
|
||||||
|
func SortingFromQuery(c fiber.Ctx, defaultField string, allowedFields []string) SortingParams {
|
||||||
|
sort := c.Query("sort", defaultField)
|
||||||
|
direction := c.Query("direction", "asc")
|
||||||
|
|
||||||
|
// Validate sort field
|
||||||
|
isAllowed := false
|
||||||
|
for _, field := range allowedFields {
|
||||||
|
if field == sort {
|
||||||
|
isAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isAllowed {
|
||||||
|
sort = defaultField
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate direction
|
||||||
|
if direction != "asc" && direction != "desc" {
|
||||||
|
direction = "asc"
|
||||||
|
}
|
||||||
|
|
||||||
|
return SortingParams{
|
||||||
|
Field: sort,
|
||||||
|
Direction: direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterParams holds common filter parameters.
|
||||||
|
type FilterParams struct {
|
||||||
|
Search string
|
||||||
|
Status string
|
||||||
|
DateFrom string
|
||||||
|
DateTo string
|
||||||
|
Custom map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FiltersFromQuery extracts common filter parameters from query string.
|
||||||
|
func FiltersFromQuery(c fiber.Ctx) FilterParams {
|
||||||
|
return FilterParams{
|
||||||
|
Search: c.Query("search"),
|
||||||
|
Status: c.Query("status"),
|
||||||
|
DateFrom: c.Query("date_from"),
|
||||||
|
DateTo: c.Query("date_to"),
|
||||||
|
Custom: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCustomFilter adds a custom filter parameter.
|
||||||
|
func (f *FilterParams) WithCustomFilter(c fiber.Ctx, name string) *FilterParams {
|
||||||
|
if value := c.Query(name); value != "" {
|
||||||
|
f.Custom[name] = value
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryParams holds all common query parameters.
|
||||||
|
type QueryParams struct {
|
||||||
|
Pagination types.Pagination
|
||||||
|
Sorting SortingParams
|
||||||
|
Filters FilterParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryParamsFromContext extracts all common query parameters.
|
||||||
|
func QueryParamsFromContext(c fiber.Ctx, defaultSortField string, allowedSortFields []string) QueryParams {
|
||||||
|
return QueryParams{
|
||||||
|
Pagination: PaginationFromQuery(c),
|
||||||
|
Sorting: SortingFromQuery(c, defaultSortField, allowedSortFields),
|
||||||
|
Filters: FiltersFromQuery(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
// Package httputil provides HTTP utility functions for the KnowFoolery application.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response represents a standard API response.
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Meta *Meta `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta contains response metadata.
|
||||||
|
type Meta struct {
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
PageSize int `json:"page_size,omitempty"`
|
||||||
|
TotalCount int64 `json:"total_count,omitempty"`
|
||||||
|
TotalPages int `json:"total_pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResponse creates a new Response.
|
||||||
|
func NewResponse(data interface{}) Response {
|
||||||
|
return Response{
|
||||||
|
Success: true,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaginatedResponse creates a new paginated Response.
|
||||||
|
func NewPaginatedResponse(data interface{}, page, pageSize int, totalCount int64) Response {
|
||||||
|
totalPages := int(totalCount) / pageSize
|
||||||
|
if int(totalCount)%pageSize > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response{
|
||||||
|
Success: true,
|
||||||
|
Data: data,
|
||||||
|
Meta: &Meta{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK sends a 200 OK response with data.
|
||||||
|
func OK(c fiber.Ctx, data interface{}) error {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(NewResponse(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created sends a 201 Created response with data.
|
||||||
|
func Created(c fiber.Ctx, data interface{}) error {
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(NewResponse(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoContent sends a 204 No Content response.
|
||||||
|
func NoContent(c fiber.Ctx) error {
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginated sends a paginated response.
|
||||||
|
func Paginated(c fiber.Ctx, data interface{}, page, pageSize int, totalCount int64) error {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(NewPaginatedResponse(data, page, pageSize, totalCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageResponse represents a simple message response.
|
||||||
|
type MessageResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message sends a simple message response.
|
||||||
|
func Message(c fiber.Ctx, message string) error {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(MessageResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthResponse represents a health check response.
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Checks map[string]string `json:"checks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health sends a health check response.
|
||||||
|
func Health(c fiber.Ctx, service, version string, checks map[string]string) error {
|
||||||
|
status := "healthy"
|
||||||
|
for _, check := range checks {
|
||||||
|
if check != "ok" {
|
||||||
|
status = "unhealthy"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(HealthResponse{
|
||||||
|
Status: status,
|
||||||
|
Service: service,
|
||||||
|
Version: version,
|
||||||
|
Checks: checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
// Package validation provides validation utilities for the KnowFoolery application.
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
|
||||||
|
"knowfoolery/backend/shared/domain/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator wraps the go-playground validator with custom validations.
|
||||||
|
type Validator struct {
|
||||||
|
validate *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator creates a new Validator with custom validations registered.
|
||||||
|
func NewValidator() *Validator {
|
||||||
|
v := validator.New()
|
||||||
|
|
||||||
|
// Use JSON tag names in error messages
|
||||||
|
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||||
|
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||||
|
if name == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register custom validations
|
||||||
|
v.RegisterValidation("alphanum_space", validateAlphanumSpace)
|
||||||
|
v.RegisterValidation("no_html", validateNoHTML)
|
||||||
|
v.RegisterValidation("safe_text", validateSafeText)
|
||||||
|
v.RegisterValidation("player_name", validatePlayerName)
|
||||||
|
|
||||||
|
return &Validator{validate: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates a struct and returns a domain error if validation fails.
|
||||||
|
func (v *Validator) Validate(s interface{}) error {
|
||||||
|
err := v.validate.Struct(s)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validationErrors, ok := err.(validator.ValidationErrors)
|
||||||
|
if !ok {
|
||||||
|
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]string, 0, len(validationErrors))
|
||||||
|
for _, e := range validationErrors {
|
||||||
|
messages = append(messages, formatValidationError(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
errors.CodeValidationFailed,
|
||||||
|
strings.Join(messages, "; "),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVar validates a single variable.
|
||||||
|
func (v *Validator) ValidateVar(field interface{}, tag string) error {
|
||||||
|
err := v.validate.Var(field, tag)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatValidationError formats a single validation error into a readable message.
|
||||||
|
func formatValidationError(e validator.FieldError) string {
|
||||||
|
field := e.Field()
|
||||||
|
|
||||||
|
switch e.Tag() {
|
||||||
|
case "required":
|
||||||
|
return fmt.Sprintf("%s is required", field)
|
||||||
|
case "min":
|
||||||
|
return fmt.Sprintf("%s must be at least %s characters", field, e.Param())
|
||||||
|
case "max":
|
||||||
|
return fmt.Sprintf("%s must be at most %s characters", field, e.Param())
|
||||||
|
case "email":
|
||||||
|
return fmt.Sprintf("%s must be a valid email", field)
|
||||||
|
case "alphanum":
|
||||||
|
return fmt.Sprintf("%s must contain only alphanumeric characters", field)
|
||||||
|
case "alphanum_space":
|
||||||
|
return fmt.Sprintf("%s must contain only alphanumeric characters and spaces", field)
|
||||||
|
case "player_name":
|
||||||
|
return fmt.Sprintf("%s must be a valid player name (2-50 chars, alphanumeric with spaces)", field)
|
||||||
|
case "oneof":
|
||||||
|
return fmt.Sprintf("%s must be one of: %s", field, e.Param())
|
||||||
|
case "gte":
|
||||||
|
return fmt.Sprintf("%s must be greater than or equal to %s", field, e.Param())
|
||||||
|
case "lte":
|
||||||
|
return fmt.Sprintf("%s must be less than or equal to %s", field, e.Param())
|
||||||
|
case "uuid":
|
||||||
|
return fmt.Sprintf("%s must be a valid UUID", field)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s is invalid", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation functions
|
||||||
|
|
||||||
|
// validateAlphanumSpace validates that a string contains only alphanumeric characters and spaces.
|
||||||
|
func validateAlphanumSpace(fl validator.FieldLevel) bool {
|
||||||
|
str := fl.Field().String()
|
||||||
|
for _, r := range str {
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateNoHTML validates that a string contains no HTML tags.
|
||||||
|
func validateNoHTML(fl validator.FieldLevel) bool {
|
||||||
|
str := fl.Field().String()
|
||||||
|
return !strings.Contains(str, "<") && !strings.Contains(str, ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSafeText validates that a string contains no potentially dangerous patterns.
|
||||||
|
func validateSafeText(fl validator.FieldLevel) bool {
|
||||||
|
str := strings.ToLower(fl.Field().String())
|
||||||
|
dangerousPatterns := []string{
|
||||||
|
"javascript:",
|
||||||
|
"data:",
|
||||||
|
"vbscript:",
|
||||||
|
"<script",
|
||||||
|
"</script",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range dangerousPatterns {
|
||||||
|
if strings.Contains(str, pattern) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePlayerName validates a player name format.
|
||||||
|
func validatePlayerName(fl validator.FieldLevel) bool {
|
||||||
|
str := fl.Field().String()
|
||||||
|
|
||||||
|
// Length check
|
||||||
|
if len(str) < 2 || len(str) > 50 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character validation
|
||||||
|
for _, r := range str {
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common validation tags for reuse
|
||||||
|
const (
|
||||||
|
TagRequired = "required"
|
||||||
|
TagPlayerName = "required,player_name"
|
||||||
|
TagEmail = "required,email"
|
||||||
|
TagUUID = "required,uuid"
|
||||||
|
TagOptionalUUID = "omitempty,uuid"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue