This commit is contained in:
Evan Goode 2025-07-19 17:44:37 -04:00
parent 57d98fb41c
commit fc38f38e48
8 changed files with 67 additions and 60 deletions

16
api.go
View File

@ -121,20 +121,20 @@ func (app *App) APIRequestToMaybeUser(c echo.Context) (mo.Option[User], error) {
tokenMatch := bearerExp.FindStringSubmatch(authorizationHeader)
if tokenMatch == nil || len(tokenMatch) < 2 {
return mo.None[User](), NewUserError(http.StatusUnauthorized, "Malformed Authorization header")
return mo.None[User](), NewUserErrorWithCode(http.StatusUnauthorized, "Malformed Authorization header")
}
token := tokenMatch[1]
var user User
if err := app.DB.First(&user, "api_token = ?", token).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return mo.None[User](), NewUserError(http.StatusUnauthorized, "Unknown API token")
return mo.None[User](), NewUserErrorWithCode(http.StatusUnauthorized, "Unknown API token")
}
return mo.None[User](), err
}
if user.IsLocked {
return mo.None[User](), NewUserError(http.StatusForbidden, "Account is locked")
return mo.None[User](), NewUserErrorWithCode(http.StatusForbidden, "Account is locked")
}
return mo.Some(user), nil
@ -147,7 +147,7 @@ func (app *App) withAPIToken(requireLogin bool, f func(c echo.Context, user *Use
return err
}
if maybeUser.IsAbsent() && requireLogin {
return NewUserError(http.StatusUnauthorized, "Route requires authorization. Missing 'Bearer: abcdef' Authorization header")
return NewUserErrorWithCode(http.StatusUnauthorized, "Route requires authorization. Missing 'Bearer: abcdef' Authorization header")
}
return f(c, maybeUser.ToPointer())
}
@ -345,7 +345,7 @@ func (app *App) APIGetUser() func(c echo.Context) error {
uuidParam := c.Param("uuid")
if uuidParam != "" {
if !caller.IsAdmin && (caller.UUID != uuidParam) {
return NewUserError(http.StatusForbidden, "You are not authorized to access that user.")
return NewUserErrorWithCode(http.StatusForbidden, "You are not authorized to access that user.")
}
_, err := uuid.Parse(uuidParam)
@ -523,7 +523,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error {
uuidParam := c.Param("uuid")
if uuidParam != "" {
if !caller.IsAdmin && (caller.UUID != uuidParam) {
return NewUserError(http.StatusForbidden, "You are not authorized to update that user.")
return NewUserErrorWithCode(http.StatusForbidden, "You are not authorized to update that user.")
}
_, err := uuid.Parse(uuidParam)
@ -586,7 +586,7 @@ func (app *App) APIDeleteUser() func(c echo.Context) error {
uuidParam := c.Param("uuid")
if uuidParam != "" {
if !caller.IsAdmin && (caller.UUID != uuidParam) {
return NewUserError(http.StatusForbidden, "You are not authorized to update that user.")
return NewUserErrorWithCode(http.StatusForbidden, "You are not authorized to update that user.")
}
_, err := uuid.Parse(uuidParam)
@ -1075,7 +1075,7 @@ func (app *App) APIDeleteInvite() func(c echo.Context) error {
return result.Error
}
if result.RowsAffected == 0 {
return NewUserError(http.StatusNotFound, "Unknown invite code")
return NewUserErrorWithCode(http.StatusNotFound, "Unknown invite code")
}
return c.NoContent(http.StatusNoContent)

View File

@ -82,7 +82,7 @@ type UserError struct {
Code mo.Option[int]
Message string
Plural mo.Option[Plural]
Params []interface{}
Params []any
}
func (e *UserError) Error() string {
@ -93,7 +93,7 @@ func (e *UserError) Error() string {
}
func (e *UserError) TranslatedError(l *gotext.Locale) string {
translatedParams := make([]interface{}, 0, len(e.Params))
translatedParams := make([]any, 0, len(e.Params))
for _, param := range e.Params {
switch v := param.(type) {
case *UserError:
@ -110,7 +110,14 @@ func (e *UserError) TranslatedError(l *gotext.Locale) string {
return l.Get(e.Message, translatedParams...)
}
func NewUserError(code int, message string, params ...interface{}) error {
func NewUserError(message string, params ...any) error {
return &UserError{
Message: message,
Params: params,
}
}
func NewUserErrorWithCode(code int, message string, params ...any) error {
return &UserError{
Code: mo.Some(code),
Message: message,
@ -118,7 +125,7 @@ func NewUserError(code int, message string, params ...interface{}) error {
}
}
func NewBadRequestUserError(message string, params ...interface{}) error {
func NewBadRequestUserError(message string, params ...any) error {
return &UserError{
Code: mo.Some(http.StatusBadRequest),
Message: message,
@ -324,10 +331,10 @@ func (app *App) GetSkinReader(reader io.Reader) (io.Reader, error) {
}
if app.Config.SkinSizeLimit > 0 && config.Width > app.Config.SkinSizeLimit {
return nil, fmt.Errorf("skin must not be greater than %d pixels wide", app.Config.SkinSizeLimit)
return nil, NewUserError("skin must not be greater than %d pixels wide", app.Config.SkinSizeLimit)
}
mustBeMultipleError := fmt.Errorf("skin size must be a multiple of %d pixels wide by %d or %d pixels high", BASE_SKIN_WIDTH, BASE_SKIN_HEIGHT, BASE_SKIN_HEIGHT_LEGACY)
mustBeMultipleError := NewUserError("skin size must be a multiple of %d pixels wide by %d or %d pixels high", BASE_SKIN_WIDTH, BASE_SKIN_HEIGHT, BASE_SKIN_HEIGHT_LEGACY)
if config.Width%BASE_SKIN_WIDTH != 0 {
return nil, mustBeMultipleError
}
@ -351,10 +358,10 @@ func (app *App) GetCapeReader(reader io.Reader) (io.Reader, error) {
}
if app.Config.SkinSizeLimit > 0 && config.Width > app.Config.SkinSizeLimit {
return nil, fmt.Errorf("cape must not be greater than %d pixels wide", app.Config.SkinSizeLimit)
return nil, NewUserError("cape must not be greater than %d pixels wide", app.Config.SkinSizeLimit)
}
mustBeMultipleError := fmt.Errorf("cape size must be a multiple of %d pixels wide by %d pixels high", BASE_CAPE_WIDTH, BASE_CAPE_HEIGHT)
mustBeMultipleError := NewUserError("cape size must be a multiple of %d pixels wide by %d pixels high", BASE_CAPE_WIDTH, BASE_CAPE_HEIGHT)
if config.Width%BASE_CAPE_WIDTH != 0 {
return nil, mustBeMultipleError
}

View File

@ -102,7 +102,7 @@ func (app *App) GetLanguageMiddleware() func(echo.HandlerFunc) echo.HandlerFunc
}
}
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error {
return t.Templates[name].ExecuteTemplate(w, "base", data)
}
@ -155,14 +155,14 @@ func (e *WebError) TranslatedError(l *gotext.Locale) string {
return e.Err.TranslatedError(l)
}
func NewWebError(returnURL string, message string, args ...interface{}) error {
func NewWebError(returnURL string, message string, args ...any) error {
return &WebError{
Err: &UserError{Message: message, Params: args},
ReturnURL: returnURL,
}
}
func RenderHTML(templateString string, args ...interface{}) (template.HTML, error) {
func RenderHTML(templateString string, args ...any) (template.HTML, error) {
// If there are no args, skip parsing and return the "template" as-is
if len(args) == 0 {
return template.HTML(templateString), nil
@ -185,8 +185,8 @@ func RenderHTML(templateString string, args ...interface{}) (template.HTML, erro
type baseContext struct {
App *App
L *gotext.Locale
T func(string, ...interface{}) string
TN func(string, string, int, ...interface{}) string
T func(string, ...any) string
TN func(string, string, int, ...any) string
URL string
SuccessMessage string
WarningMessage string
@ -540,12 +540,12 @@ func (app *App) getPreferredPlayerName(userInfo *oidc.UserInfo) mo.Option[string
func (app *App) getIDTokenCookie(c *echo.Context) (*OIDCProvider, string, oidc.IDTokenClaims, error) {
cookie, err := (*c).Cookie(ID_TOKEN_COOKIE_NAME)
if err != nil || cookie.Value == "" {
return nil, "", oidc.IDTokenClaims{}, &UserError{Message: "Missing ID token cookie"}
return nil, "", oidc.IDTokenClaims{}, NewUserError("Missing ID token cookie")
}
idTokenBytes, err := app.DecryptCookieValue(cookie.Value)
if err != nil {
return nil, "", oidc.IDTokenClaims{}, &UserError{Message: "Invalid ID token"}
return nil, "", oidc.IDTokenClaims{}, NewUserError("Invalid ID token")
}
idToken := string(idTokenBytes)

View File

@ -79,7 +79,7 @@ type App struct {
LocaleTags []language.Tag
}
func LogInfo(args ...interface{}) {
func LogInfo(args ...any) {
if !DRASL_TEST() {
log.Println(args...)
}
@ -146,7 +146,7 @@ func makeRateLimiter(app *App) echo.MiddlewareFunc {
// TODO write an IdentifierExtractor per authlib-injector spec "Limits should be placed on users, not client IPs"
Store: middleware.NewRateLimiterMemoryStore(requestsPerSecond),
DenyHandler: func(c echo.Context, identifier string, err error) error {
return NewUserError(http.StatusTooManyRequests, "Too many requests. Try again later.")
return NewUserErrorWithCode(http.StatusTooManyRequests, "Too many requests. Try again later.")
},
})
}
@ -615,7 +615,7 @@ func setup(config *Config) *App {
// Post-setup
// Make sure all DefaultAdmins are admins
err = app.DB.Table("users").Where("username in (?)", config.DefaultAdmins).Updates(map[string]interface{}{"is_admin": true}).Error
err = app.DB.Table("users").Where("username in (?)", config.DefaultAdmins).Updates(map[string]any{"is_admin": true}).Error
Check(err)
// Print an initial invite link if necessary

View File

@ -72,14 +72,14 @@ func IsValidSkinModel(model string) bool {
func UUIDToID(uuid string) (string, error) {
if len(uuid) != 36 {
return "", &UserError{Message: "invalid UUID"}
return "", NewUserError("invalid UUID")
}
return strings.ReplaceAll(uuid, "-", ""), nil
}
func IDToUUID(id string) (string, error) {
if len(id) != 32 {
return "", &UserError{Message: "invalid ID"}
return "", NewUserError("invalid ID")
}
return id[0:8] + "-" + id[8:12] + "-" + id[12:16] + "-" + id[16:20] + "-" + id[20:], nil
}
@ -101,7 +101,7 @@ func ParseUUID(idOrUUID string) (string, error) {
}
return idOrUUID, nil
}
return "", &UserError{Message: "invalid ID or UUID"}
return "", NewUserError("invalid ID or UUID")
}
type Plural struct {
@ -111,11 +111,11 @@ type Plural struct {
func (app *App) ValidatePlayerName(playerName string) error {
if app.TransientLoginEligible(playerName) {
return &UserError{Message: "name is reserved for transient login"}
return NewUserError("name is reserved for transient login")
}
maxLength := Constants.MaxPlayerNameLength
if playerName == "" {
return &UserError{Message: "can't be blank"}
return NewUserError("can't be blank")
}
if len(playerName) > maxLength {
return &UserError{
@ -124,12 +124,12 @@ func (app *App) ValidatePlayerName(playerName string) error {
Message: "can't be longer than %d characters",
N: maxLength,
}),
Params: []interface{}{maxLength},
Params: []any{maxLength},
}
}
if !app.ValidPlayerNameRegex.MatchString(playerName) {
return &UserError{Message: "must match the following regular expression: %s", Params: []interface{}{app.Config.ValidPlayerNameRegex}}
return NewUserError("must match the following regular expression: %s", app.Config.ValidPlayerNameRegex)
}
return nil
}
@ -144,7 +144,7 @@ func (app *App) ValidateUsername(username string) error {
if emailErr == nil {
return nil
}
return &UserError{Message: "neither a valid player name (%s) nor an email address", Params: []interface{}{playerNameErr}}
return NewUserError("neither a valid player name (%s) nor an email address", playerNameErr)
}
func (app *App) ValidatePlayerNameOrUUID(player string) error {
@ -152,7 +152,7 @@ func (app *App) ValidatePlayerNameOrUUID(player string) error {
if err != nil {
_, uuidErr := uuid.Parse(player)
if uuidErr != nil {
return &UserError{Message: "not a valid player name or UUID"}
return NewUserError("not a valid player name or UUID")
}
return nil
}
@ -161,7 +161,7 @@ func (app *App) ValidatePlayerNameOrUUID(player string) error {
func (app *App) ValidateMaxPlayerCount(maxPlayerCount int) error {
if maxPlayerCount < 0 && maxPlayerCount != app.Constants.MaxPlayerCountUnlimited && maxPlayerCount != app.Constants.MaxPlayerCountUseDefault {
return &UserError{Message: "must be greater than 0, or use -1 to indicate unlimited players, or use -2 to use the system default"}
return NewUserError("must be greater than 0, or use -1 to indicate unlimited players, or use -2 to use the system default")
}
return nil
}
@ -209,7 +209,7 @@ func (app *App) TransientLoginEligible(playerName string) bool {
func (app *App) ValidatePassword(password string) error {
if password == "" {
return &UserError{Message: "can't be blank"}
return NewUserError("can't be blank")
}
if len(password) < app.Config.MinPasswordLength {
return &UserError{
@ -218,7 +218,7 @@ func (app *App) ValidatePassword(password string) error {
Message: "must be longer than %d characters",
N: app.Config.MinPasswordLength,
}),
Params: []interface{}{app.Config.MinPasswordLength},
Params: []any{app.Config.MinPasswordLength},
}
}
return nil
@ -387,7 +387,7 @@ const (
)
func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Client {
token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (any, error) {
return app.PrivateKey.Public(), nil
})
if err != nil {

View File

@ -115,7 +115,7 @@ func (app *App) CreatePlayer(
Plural: mo.Some(Plural{
Message: "You are only allowed to own %d players", N: maxPlayerCount,
}),
Params: []interface{}{maxPlayerCount},
Params: []any{maxPlayerCount},
}
}
@ -403,7 +403,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
if res.StatusCode != http.StatusOK {
log.Printf("Request to registration server at %s resulted in status code %d\n", base.String(), res.StatusCode)
return nil, &UserError{Message: "registration server returned an error"}
return nil, NewUserError("registration server returned an error")
}
var idRes PlayerNameToIDResponse
@ -429,7 +429,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
if res.StatusCode != http.StatusOK {
log.Printf("Request to registration server at %s resulted in status code %d\n", base.String(), res.StatusCode)
return nil, &UserError{Message: "registration server returned an error"}
return nil, NewUserError("registration server returned an error")
}
var profileRes SessionProfileResponse
@ -465,7 +465,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
}
if texture.Textures.Skin == nil {
return nil, &UserError{Message: "player does not have a skin"}
return nil, NewUserError("player does not have a skin")
}
res, err = MakeHTTPClient().Get(texture.Textures.Skin.URL)
if err != nil {
@ -479,7 +479,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
}
img, ok := rgba_img.(*image.NRGBA)
if !ok {
return nil, &UserError{Message: "invalid image"}
return nil, NewUserError("invalid image")
}
challenge := make([]byte, 64)
@ -497,19 +497,19 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
}
if challengeToken == nil {
return nil, &UserError{Message: "missing challenge token"}
return nil, NewUserError("missing challenge token")
}
correctChallenge := app.GetChallenge(playerName, *challengeToken)
if !bytes.Equal(challenge, correctChallenge) {
return nil, &UserError{Message: "skin does not match"}
return nil, NewUserError("skin does not match")
}
return &details, nil
}
}
return nil, &UserError{Message: "registration server didn't return textures"}
return nil, NewUserError("registration server didn't return textures")
}
func MakeChallengeToken() (string, error) {
@ -591,11 +591,11 @@ func (app *App) InvalidateUser(db *gorm.DB, user *User) error {
func (app *App) DeletePlayer(caller *User, player *Player) error {
if !app.Config.AllowAddingDeletingPlayers && !caller.IsAdmin {
return NewUserError(http.StatusForbidden, "You are not allowed to delete players.")
return NewUserErrorWithCode(http.StatusForbidden, "You are not allowed to delete players.")
}
if caller.UUID != player.UserUUID && !caller.IsAdmin {
return NewUserError(http.StatusForbidden, "You don't own that player.")
return NewUserErrorWithCode(http.StatusForbidden, "You don't own that player.")
}
if err := app.DB.Delete(player).Error; err != nil {

View File

@ -223,7 +223,7 @@ func (ts *TestSuite) Get(t *testing.T, server *echo.Echo, path string, cookies [
return rec
}
func (ts *TestSuite) Delete(t *testing.T, server *echo.Echo, path string, payload interface{}, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
func (ts *TestSuite) Delete(t *testing.T, server *echo.Echo, path string, payload any, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
body, err := json.Marshal(payload)
assert.Nil(t, err)
req := httptest.NewRequest(http.MethodDelete, path, bytes.NewBuffer(body))
@ -288,7 +288,7 @@ func (ts *TestSuite) PutMultipart(t *testing.T, server *echo.Echo, path string,
return rec
}
func (ts *TestSuite) PostJSON(t *testing.T, server *echo.Echo, path string, payload interface{}, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
func (ts *TestSuite) PostJSON(t *testing.T, server *echo.Echo, path string, payload any, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
body, err := json.Marshal(payload)
assert.Nil(t, err)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBuffer(body))
@ -305,7 +305,7 @@ func (ts *TestSuite) PostJSON(t *testing.T, server *echo.Echo, path string, payl
return rec
}
func (ts *TestSuite) PatchJSON(t *testing.T, server *echo.Echo, path string, payload interface{}, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
func (ts *TestSuite) PatchJSON(t *testing.T, server *echo.Echo, path string, payload any, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
body, err := json.Marshal(payload)
assert.Nil(t, err)
req := httptest.NewRequest(http.MethodPatch, path, bytes.NewBuffer(body))

16
user.go
View File

@ -356,14 +356,14 @@ func (app *App) CreateUser(
return user, nil
}
var PasswordLoginNotAllowedError error = NewUserError(http.StatusUnauthorized, "Password login is not allowed.")
var PasswordLoginNotAllowedError error = NewUserErrorWithCode(http.StatusUnauthorized, "Password login is not allowed.")
func (app *App) AuthenticateUserForMigration(username string, password string) (User, error) {
var user User
result := app.DB.First(&user, "username = ?", username)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return User{}, NewUserError(http.StatusUnauthorized, "User not found.")
return User{}, NewUserErrorWithCode(http.StatusUnauthorized, "User not found.")
}
return User{}, result.Error
}
@ -378,7 +378,7 @@ func (app *App) AuthenticateUserForMigration(username string, password string) (
}
if !bytes.Equal(passwordHash, user.PasswordHash) {
return User{}, NewUserError(http.StatusUnauthorized, "Incorrect password.")
return User{}, NewUserErrorWithCode(http.StatusUnauthorized, "Incorrect password.")
}
return user, nil
@ -389,7 +389,7 @@ func (app *App) AuthenticateUser(username string, password string) (User, error)
result := app.DB.First(&user, "username = ?", username)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return User{}, NewUserError(http.StatusUnauthorized, "User not found.")
return User{}, NewUserErrorWithCode(http.StatusUnauthorized, "User not found.")
}
return User{}, result.Error
}
@ -404,11 +404,11 @@ func (app *App) AuthenticateUser(username string, password string) (User, error)
}
if !bytes.Equal(passwordHash, user.PasswordHash) {
return User{}, NewUserError(http.StatusUnauthorized, "Incorrect password.")
return User{}, NewUserErrorWithCode(http.StatusUnauthorized, "Incorrect password.")
}
if user.IsLocked {
return User{}, NewUserError(http.StatusForbidden, "User is locked.")
return User{}, NewUserErrorWithCode(http.StatusForbidden, "User is locked.")
}
return user, nil
@ -538,7 +538,7 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error {
func (app *App) DeleteUser(caller *User, user *User) error {
if !caller.IsAdmin && caller.UUID != user.UUID {
return NewUserError(http.StatusForbidden, "You are not an admin.")
return NewUserErrorWithCode(http.StatusForbidden, "You are not an admin.")
}
oldSkinHashes := make([]*string, 0, len(user.Players))
@ -661,7 +661,7 @@ func (app *App) DeleteOIDCIdentity(
return result.Error
}
if result.RowsAffected == 0 {
return NewUserError(http.StatusNotFound, "No linked %s account found.", providerName)
return NewUserErrorWithCode(http.StatusNotFound, "No linked %s account found.", providerName)
}
var count int64