diff --git a/Makefile b/Makefile index 1903772..76bbceb 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ npm-install: npm install swag: - $(SWAG) init --generalInfo api.go --output . --outputTypes json + $(SWAG) init --generalInfo api.go --output ./assets/ --outputTypes json prebuild: npm-install swag node esbuild.config.js diff --git a/api.go b/api.go index e27803c..330c869 100644 --- a/api.go +++ b/api.go @@ -11,6 +11,8 @@ import ( "gorm.io/gorm" "io" "net/http" + "os" + "path" "regexp" "strconv" "strings" @@ -279,6 +281,14 @@ func (app *App) inviteToAPIInvite(invite *Invite) (APIInvite, error) { }, nil } +func (app *App) APISwagger() func(c echo.Context) error { + swaggerPath := path.Join(app.Config.DataDirectory, "assets", "swagger.json") + swaggerBlob := Unwrap(os.ReadFile(swaggerPath)) + return func(c echo.Context) error { + return c.JSONBlob(http.StatusOK, swaggerBlob) + } +} + // APIGetUsers godoc // // @Summary Get users @@ -312,32 +322,10 @@ func (app *App) APIGetUsers() func(c echo.Context) error { }) } -// APIGetSelf godoc -// -// @Summary Get own account -// @Description Get details of your own account -// @Tags users -// @Accept json -// @Produce json -// @Success 200 {object} APIUser -// @Failure 403 {object} APIError -// @Failure 429 {object} APIError -// @Failure 500 {object} APIError -// @Router /drasl/api/v2/user [get] -func (app *App) APIGetSelf() func(c echo.Context) error { - return app.withAPIToken(true, func(c echo.Context, user *User) error { - apiUser, err := app.userToAPIUser(user) - if err != nil { - return err - } - return c.JSON(http.StatusOK, apiUser) - }) -} - // APIGetUser godoc // -// @Summary Get user by UUID -// @Description Get details of a user by their UUID. Requires admin privileges. +// @Summary Get user details +// @Description Get details of a user, either the calling user (GET /user) or the user with the specified UUID (GET /users/{uuid}). Getting details of another user requires admin privileges. // @Tags users // @Accept json // @Produce json @@ -349,23 +337,34 @@ func (app *App) APIGetSelf() func(c echo.Context) error { // @Failure 404 {object} APIError // @Failure 500 {object} APIError // @Router /drasl/api/v2/users/{uuid} [get] +// @Router /drasl/api/v2/user [get] func (app *App) APIGetUser() func(c echo.Context) error { - return app.withAPITokenAdmin(func(c echo.Context, user *User) error { - uuid_ := c.Param("uuid") - _, err := uuid.Parse(uuid_) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") - } + return app.withAPIToken(true, func(c echo.Context, caller *User) error { + targetUser := caller - var profileUser User - if err := app.DB.First(&profileUser, "uuid = ?", uuid_).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") + 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 err + + _, err := uuid.Parse(uuidParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + } + + var targetUserStruct User + if err := app.DB.First(&targetUserStruct, "uuid = ?", uuidParam).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") + } + return err + } + + targetUser = &targetUserStruct } - apiUser, err := app.userToAPIUser(&profileUser) + apiUser, err := app.userToAPIUser(targetUser) if err != nil { return err } @@ -382,21 +381,21 @@ type APICreateUserRequest struct { Username string `json:"username" example:"MyUsername"` // Username of the new user. Can be different from the user's player name. Password *string `json:"password" example:"hunter2"` // Plaintext password. Not needed if OIDCIdentitySpecs are supplied. OIDCIdentitySpecs []APIOIDCIdentitySpec `json:"oidcIdentities"` - IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin - IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled) - RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response - ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated. - ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md. - InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`. - PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used. - FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. - PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used. - SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed. - SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`. - SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`. - CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`. - CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`. - MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value. + IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin + IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled) + RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response + ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated. + ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md. + InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`. + PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used. + FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. + PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used. + SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed. + SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..."` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`. + SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`. + CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..."` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`. + CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`. + MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value. } type APICreateUserResponse struct { @@ -407,7 +406,7 @@ type APICreateUserResponse struct { // APICreateUser godoc // // @Summary Create a new user -// @Description Create a new user. Requires admin privileges. +// @Description Register and create a new user. Can be called without an API token. // @Tags users // @Accept json // @Produce json @@ -487,19 +486,19 @@ func (app *App) APICreateUser() func(c echo.Context) error { } type APIUpdateUserRequest struct { - Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password - IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges. - IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user. - ResetAPIToken bool `json:"resetApiToken" example:"true"` // Pass `true` to reset the user's API token - ResetMinecraftToken bool `json:"resetMinecraftToken" example:"true"` // Pass `true` to reset the user's Minecraft token - PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. - MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value. + Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password + IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges. + IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user. + ResetAPIToken bool `json:"resetApiToken" example:"false"` // Pass `true` to reset the user's API token + ResetMinecraftToken bool `json:"resetMinecraftToken" example:"false"` // Pass `true` to reset the user's Minecraft token + PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. + MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value. } // APIUpdateUser godoc // // @Summary Update a user -// @Description Update an existing user. Requires admin privileges. +// @Description Update an existing user, either the calling user (PATCH /user) or the user with the specified UUID (PATCH /users/{uuid}). Updating another user requires admin privileges. // @Tags users // @Accept json // @Produce json @@ -509,79 +508,44 @@ type APIUpdateUserRequest struct { // @Failure 400 {object} APIError // @Failure 403 {object} APIError // @Failure 404 {object} APIError +// @Failure 429 {object} APIError // @Failure 500 {object} APIError // @Router /drasl/api/v2/users/{uuid} [patch] +// @Router /drasl/api/v2/user [patch] func (app *App) APIUpdateUser() func(c echo.Context) error { - return app.withAPITokenAdmin(func(c echo.Context, caller *User) error { + return app.withAPIToken(true, func(c echo.Context, caller *User) error { req := new(APIUpdateUserRequest) if err := c.Bind(req); err != nil { return err } - uuid_ := c.Param("uuid") - _, err := uuid.Parse(uuid_) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") - } - - var targetUser User - if err := app.DB.First(&targetUser, "uuid = ?", uuid_).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") + targetUser := caller + 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 err + + _, err := uuid.Parse(uuidParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + } + + var targetUserStruct User + if err := app.DB.First(&targetUserStruct, "uuid = ?", uuidParam).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") + } + return err + } + + targetUser = &targetUserStruct } updatedUser, err := app.UpdateUser( app.DB, caller, - targetUser, // user - req.Password, - req.IsAdmin, - req.IsLocked, - req.ResetAPIToken, - req.ResetMinecraftToken, - req.PreferredLanguage, - req.MaxPlayerCount, - ) - if err != nil { - return err - } - - apiUser, err := app.userToAPIUser(&updatedUser) - if err != nil { - return err - } - return c.JSON(http.StatusOK, apiUser) - }) -} - -// APIUpdateSelf godoc -// -// @Summary Update own account -// @Description Update details of your own account. -// @Tags users -// @Accept json -// @Produce json -// @Param APIUpdateUserRequest body APIUpdateUserRequest true "New properties of the user" -// @Success 200 {object} APIUser -// @Failure 400 {object} APIError -// @Failure 403 {object} APIError -// @Failure 404 {object} APIError -// @Failure 429 {object} APIError -// @Failure 500 {object} APIError -// @Router /drasl/api/v2/user [patch] -func (app *App) APIUpdateSelf() func(c echo.Context) error { - return app.withAPIToken(true, func(c echo.Context, user *User) error { - req := new(APIUpdateUserRequest) - if err := c.Bind(req); err != nil { - return err - } - - updatedUser, err := app.UpdateUser( - app.DB, - user, // caller - *user, + *targetUser, req.Password, req.IsAdmin, req.IsLocked, @@ -605,7 +569,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error { // APIDeleteUser godoc // // @Summary Delete user -// @Description Delete a user. This action cannot be undone. Requires admin privileges. +// @Description Delete a user, either the calling user (DELETE /user) or the user with the specified UUID (DELETE /users/{uuid}). This action cannot be undone. Deleting another user requires admin privileges. // @Tags users // @Accept json // @Produce json @@ -614,45 +578,34 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error { // @Failure 403 {object} APIError // @Failure 404 {object} APIError // @Failure 500 {object} APIError +// @Router /drasl/api/v2/user [delete] // @Router /drasl/api/v2/users/{uuid} [delete] func (app *App) APIDeleteUser() func(c echo.Context) error { - return app.withAPITokenAdmin(func(c echo.Context, user *User) error { - uuid_ := c.Param("uuid") - _, err := uuid.Parse(uuid_) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + return app.withAPIToken(true, func(c echo.Context, caller *User) error { + targetUser := caller + uuidParam := c.Param("uuid") + if uuidParam != "" { + if !caller.IsAdmin && (caller.UUID != uuidParam) { + return NewUserError(http.StatusForbidden, "You are not authorized to update that user.") + } + + _, err := uuid.Parse(uuidParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + } + + var targetUserStruct User + if err := app.DB.First(&targetUserStruct, "uuid = ?", uuidParam).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") + } + return err + } + + targetUser = &targetUserStruct } - var targetUser User - result := app.DB.First(&targetUser, "uuid = ?", uuid_) - if result.Error != nil { - return echo.NewHTTPError(http.StatusNotFound, "Unknown UUID") - } - - err = app.DeleteUser(user, &targetUser) - if err != nil { - return err - } - - return c.NoContent(http.StatusNoContent) - }) -} - -// APIDeleteSelf godoc -// -// @Summary Delete own account -// @Description Delete your own account. This action cannot be undone. -// @Tags users -// @Accept json -// @Produce json -// @Success 204 -// @Failure 401 {object} APIError -// @Failure 429 {object} APIError -// @Failure 500 {object} APIError -// @Router /drasl/api/v2/user [delete] -func (app *App) APIDeleteSelf() func(c echo.Context) error { - return app.withAPIToken(true, func(c echo.Context, user *User) error { - err := app.DeleteUser(user, user) + err := app.DeleteUser(caller, targetUser) if err != nil { return err } @@ -738,23 +691,23 @@ func (app *App) APIGetPlayers() func(c echo.Context) error { } type APICreatePlayerRequest struct { - Name string `json:"name" example:"MyPlayerName"` // Player name. - UserUUID *string `json:"userUuid" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"` // Optional. UUID of the owning user. If omitted, the player will be added to the calling user's account. - ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the new player. If omitted, a random UUID will be generated. - ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md. - FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. - ChallengeToken *string `json:"challengeToken" example:"a484528c86725b7b5ac3b47e2f973efd"` // Challenge token to use when verifying ownership of another player. Call /drasl/api/v2/challenge-skin first to get a skin and token. See `RequireSkinVerification` in configuration.md. - SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed. - SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`. - SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`. - CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`. - CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`. + Name string `json:"name" example:"MyPlayerName"` // Player name. + UserUUID *string `json:"userUuid" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"` // Optional. UUID of the owning user. If omitted, the player will be added to the calling user's account. + ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the new player. If omitted, a random UUID will be generated. + ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md. + FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. + ChallengeToken *string `json:"challengeToken" example:"iK1B2FzLc5fMP94VmUR3KC"` // Challenge token to use when verifying ownership of another player. Call /drasl/api/v2/challenge-skin first to get a skin and token. See `RequireSkinVerification` in configuration.md. + SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed. + SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..."` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`. + SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`. + CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..."` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`. + CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`. } // APICreatePlayer godoc // // @Summary Create a new player -// @Description Create a new player +// @Description Create a new player for an existing Drasl user. // @Tags players // @Accept json // @Produce json @@ -817,15 +770,15 @@ func (app *App) APICreatePlayer() func(c echo.Context) error { } type APIUpdatePlayerRequest struct { - Name *string `json:"name" example:"MyPlayerName"` // Optional. New player name. Can be different from the user's username. - FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Optional. New fallback player. Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. - SkinModel *string `json:"skinModel" example:"classic"` // Optional. New skin model. Either "classic" or "slim". - SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` - SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file - DeleteSkin bool `json:"deleteSkin"` // Pass `true` to delete the user's existing skin - CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. - CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file - DeleteCape bool `json:"deleteCape"` // Pass `true` to delete the user's existing cape + Name *string `json:"name" example:"MyPlayerName"` // Optional. New player name. Can be different from the user's username. + FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Optional. New fallback player. Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead. + SkinModel *string `json:"skinModel" example:"classic"` // Optional. New skin model. Either "classic" or "slim". + SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..."` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. + SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file + DeleteSkin bool `json:"deleteSkin"` // Pass `true` to delete the user's existing skin + CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..."` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. + CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file + DeleteCape bool `json:"deleteCape"` // Pass `true` to delete the user's existing cape } // APIUpdatePlayer godoc @@ -905,7 +858,7 @@ func (app *App) APIUpdatePlayer() func(c echo.Context) error { // APIDeletePlayer godoc // // @Summary Delete player -// @Description Delete a player. This action cannot be undone. +// @Description Delete a player. This action cannot be undone. Requires admin privileges unless you own the player. // @Tags players // @Accept json // @Produce json @@ -942,9 +895,8 @@ func (app *App) APIDeletePlayer() func(c echo.Context) error { } type APICreateOIDCIdentityRequest struct { - UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"` - Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"` - Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"` + Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"` + Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"` } // APICreateOIDCIdentity godoc @@ -953,12 +905,16 @@ type APICreateOIDCIdentityRequest struct { // @Tags users // @Accept json // @Produce json -// @Success 200 -// @Failure 400 {object} APIError -// @Failure 401 {object} APIError -// @Failure 403 {object} APIError -// @Failure 500 {object} APIError -// @Router /drasl/api/v2/oidc-identities [post] +// @Param uuid path string true "User UUID" +// @Param APICreateOIDCIdentityRequest body APICreateOIDCIdentityRequest true "OIDC identity to link to the user" +// @Success 200 {object} APIOIDCIdentity +// @Failure 400 {object} APIError +// @Failure 401 {object} APIError +// @Failure 403 {object} APIError +// @Failure 404 {object} APIError +// @Failure 500 {object} APIError +// @Router /drasl/api/v2/user/oidc-identities [post] +// @Router /drasl/api/v2/users/{uuid}/oidc-identities [post] func (app *App) APICreateOIDCIdentity() func(c echo.Context) error { return app.withAPIToken(true, func(c echo.Context, caller *User) error { req := new(APICreateOIDCIdentityRequest) @@ -967,8 +923,13 @@ func (app *App) APICreateOIDCIdentity() func(c echo.Context) error { } userUUID := caller.UUID - if req.UserUUID != nil { - userUUID = *req.UserUUID + uuidParam := c.Param("uuid") + if uuidParam != "" { + _, err := uuid.Parse(uuidParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + } + userUUID = uuidParam } oidcIdentity, err := app.CreateOIDCIdentity(caller, userUUID, req.Issuer, req.Subject) @@ -985,8 +946,7 @@ func (app *App) APICreateOIDCIdentity() func(c echo.Context) error { } type APIDeleteOIDCIdentityRequest struct { - UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"` - Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"` + Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"` } // APIDeleteOIDCIdentity godoc @@ -995,12 +955,16 @@ type APIDeleteOIDCIdentityRequest struct { // @Tags users // @Accept json // @Produce json +// @Param uuid path string true "User UUID" +// @Param APIDeleteOIDCIdentityRequest body APIDeleteOIDCIdentityRequest true "Issuer of the OIDC provider to unlink from the user" // @Success 204 +// @Failure 400 {object} APIError // @Failure 401 {object} APIError // @Failure 403 {object} APIError // @Failure 404 {object} APIError // @Failure 500 {object} APIError -// @Router /drasl/api/v2/oidc-identities [delete] +// @Router /drasl/api/v2/user/oidc-identities [delete] +// @Router /drasl/api/v2/users/{uuid}/oidc-identities [delete] func (app *App) APIDeleteOIDCIdentity() func(c echo.Context) error { return app.withAPIToken(true, func(c echo.Context, caller *User) error { req := new(APIDeleteOIDCIdentityRequest) @@ -1009,8 +973,13 @@ func (app *App) APIDeleteOIDCIdentity() func(c echo.Context) error { } userUUID := caller.UUID - if req.UserUUID != nil { - userUUID = *req.UserUUID + uuidParam := c.Param("uuid") + if uuidParam != "" { + _, err := uuid.Parse(uuidParam) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid UUID") + } + userUUID = uuidParam } oidcProvider, ok := app.OIDCProvidersByIssuer[req.Issuer] @@ -1113,31 +1082,41 @@ func (app *App) APIDeleteInvite() func(c echo.Context) error { }) } +type APIGetChallengeSkinRequest struct { + PlayerName string `json:"playerName" example:"Notch"` +} + type APIChallenge struct { - ChallengeSkinBase64 string `json:"challengeSkinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Base64-encoded skin PNG. Example value truncated for brevity. - ChallengeToken string `json:"challengeToken" example:"414cc23d6eebee3b17a453d6b9800be3e5a4627fd3b0ee54d7c37d03b2596e44"` // Challenge token that must be passed when registering with a challenge skin + ChallengeSkinBase64 string `json:"challengeSkinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..."` // Base64-encoded skin PNG. Example value truncated for brevity. + ChallengeToken string `json:"challengeToken" example:"iK1B2FzLc5fMP94VmUR3KC"` // Challenge token that must be passed when registering with a challenge skin } // APIGetChallengeSkin godoc // // @Summary Get a challenge skin/token -// @Description Get a challenge skin and challenge token for a username, for registration purposes. See the `RequireSkinVerification` configuration option. +// @Description Get a challenge skin and challenge token for a player name for registration or player creation purposes. See the `ImportExistingPlayer.RequireSkinVerification` configuration option. // @Tags users, players // @Accept json // @Produce json -// @Success 200 {object} APIChallenge -// @Failure 500 {object} APIError +// @Param APIGetChallengeSkinRequest body APIGetChallengeSkinRequest true "Player name for the challenge skin" +// @Success 200 {object} APIChallenge +// @Success 400 {object} APIError +// @Failure 500 {object} APIError // @Router /drasl/api/v2/challenge-skin [get] func (app *App) APIGetChallengeSkin() func(c echo.Context) error { return app.withAPIToken(false, func(c echo.Context, _ *User) error { - username := c.QueryParam("username") + req := new(APIGetChallengeSkinRequest) + if err := c.Bind(req); err != nil { + return err + } + playerName := req.PlayerName challengeToken, err := MakeChallengeToken() if err != nil { return err } - challengeSkinBytes, err := app.GetChallengeSkin(username, challengeToken) + challengeSkinBytes, err := app.GetChallengeSkin(playerName, challengeToken) if err != nil { return err } @@ -1151,8 +1130,8 @@ func (app *App) APIGetChallengeSkin() func(c echo.Context) error { } type APILoginResponse struct { - User APIUser `json:"user"` // The logged-in user - APIToken string `json:"token" example:"Bq608AtLeG7emJOdvXHYxL"` // An API token for the user + User APIUser `json:"user"` // The logged-in user + APIToken string `json:"apiToken" example:"Bq608AtLeG7emJOdvXHYxL"` // An API token for the user } type APILoginRequest struct { @@ -1162,24 +1141,25 @@ type APILoginRequest struct { // APILogin godoc // -// @Summary Get a token -// @Description Get a token for login credentials. -// @Tags users, auth +// @Summary Authenticate and receive an API token +// @Description Authenticate with username and password and receive an API token. Can be called without an API token. +// @Tags users // @Accept json // @Produce json -// @Success 200 {object} APILoginResponse -// @Failure 400 {object} APIError -// @Failure 401 {object} APIError -// @Failure 403 {object} APIError -// @Failure 429 {object} APIError -// @Failure 500 {object} APIError +// @Param APILoginRequest body APILoginRequest true "Drasl username and password" +// @Success 200 {object} APILoginResponse +// @Failure 400 {object} APIError +// @Failure 401 {object} APIError +// @Failure 403 {object} APIError +// @Failure 429 {object} APIError +// @Failure 500 {object} APIError // @Router /drasl/api/v2/login [post] func (app *App) APILogin() func(c echo.Context) error { return app.withAPIToken(false, func(c echo.Context, _ *User) error { var req APILoginRequest err := c.Bind(&req) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformed JSON request") + return err } user, err := app.AuthenticateUser(req.Username, req.Password) diff --git a/api_test.go b/api_test.go index 9086464..10349b1 100644 --- a/api_test.go +++ b/api_test.go @@ -1,12 +1,12 @@ package main import ( + "bytes" "encoding/json" "github.com/stretchr/testify/assert" "gorm.io/gorm" "net/http" "net/http/httptest" - "net/url" "testing" ) @@ -302,12 +302,14 @@ func (ts *TestSuite) testAPIGetChallengeSkin(t *testing.T) { username := "user" user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username) - ts.Get(t, ts.Server, DRASL_API_PREFIX+"/challenge-skin", nil, &user.APIToken) - req := httptest.NewRequest(http.MethodGet, DRASL_API_PREFIX+"/challenge-skin", nil) + payload := APIGetChallengeSkinRequest{ + PlayerName: "foo", + } + body, err := json.Marshal(payload) + assert.Nil(t, err) + req := httptest.NewRequest(http.MethodGet, DRASL_API_PREFIX+"/challenge-skin", bytes.NewBuffer(body)) + req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+user.APIToken) - req.URL.RawQuery = url.Values{ - "username": {"foo"}, - }.Encode() rec := httptest.NewRecorder() ts.Server.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) @@ -616,11 +618,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // admin should be able to create OIDC identities for themself payload := APICreateOIDCIdentityRequest{ - UserUUID: &admin.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, - Subject: provider1Subject1, + Issuer: fakeOIDCProvider1.Config.Issuer, + Subject: provider1Subject1, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusOK, rec.Code) var apiOIDCIdentity APIOIDCIdentity assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity)) @@ -638,7 +639,7 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { Issuer: fakeOIDCProvider2.Config.Issuer, Subject: provider2Subject1, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusOK, rec.Code) var apiOIDCIdentity APIOIDCIdentity assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity)) @@ -648,11 +649,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // admin should be able to create OIDC identities for other users payload := APICreateOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, - Subject: provider1Subject2, + Issuer: fakeOIDCProvider1.Config.Issuer, + Subject: provider1Subject2, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusOK, rec.Code) var apiOIDCIdentity APIOIDCIdentity assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity)) @@ -662,11 +662,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Duplicate issuer and subject should fail payload := APICreateOIDCIdentityRequest{ - UserUUID: &admin.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, - Subject: provider1Subject1, + Issuer: fakeOIDCProvider1.Config.Issuer, + Subject: provider1Subject1, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusBadRequest, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) @@ -675,11 +674,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Duplicate issuer on the same user should fail payload := APICreateOIDCIdentityRequest{ - UserUUID: &admin.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, - Subject: provider1Subject3, + Issuer: fakeOIDCProvider1.Config.Issuer, + Subject: provider1Subject3, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusBadRequest, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) @@ -688,11 +686,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Non-admin should not be able to link an OIDC identity for another user payload := APICreateOIDCIdentityRequest{ - UserUUID: &admin.UUID, - Issuer: fakeOIDCProvider2.Config.Issuer, - Subject: provider2Subject3, + Issuer: fakeOIDCProvider2.Config.Issuer, + Subject: provider2Subject3, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusBadRequest, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) @@ -701,11 +698,10 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Non-admin should be able to link an OIDC identity for themself payload := APICreateOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider2.Config.Issuer, - Subject: provider2Subject2, + Issuer: fakeOIDCProvider2.Config.Issuer, + Subject: provider2Subject2, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusOK, rec.Code) var apiOIDCIdentity APIOIDCIdentity assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity)) @@ -715,29 +711,26 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // admin should be able to delete OIDC identity for other users payload := APIDeleteOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, + Issuer: fakeOIDCProvider1.Config.Issuer, } - rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusNoContent, rec.Code) } { // Add the identity back for future tests... payload := APICreateOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, - Subject: provider1Subject2, + Issuer: fakeOIDCProvider1.Config.Issuer, + Subject: provider1Subject2, } - rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken) + rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &admin.APIToken) assert.Equal(t, http.StatusOK, rec.Code) } { // Non-admin user should not be able to delete OIDC identity for other users payload := APIDeleteOIDCIdentityRequest{ - UserUUID: &admin.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, + Issuer: fakeOIDCProvider1.Config.Issuer, } - rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusBadRequest, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) @@ -746,19 +739,17 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Non-admin user should be able to delete OIDC identity for themself payload := APIDeleteOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider2.Config.Issuer, + Issuer: fakeOIDCProvider2.Config.Issuer, } - rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusNoContent, rec.Code) } { // Can't delete nonexistent OIDC identity payload := APIDeleteOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider2.Config.Issuer, + Issuer: fakeOIDCProvider2.Config.Issuer, } - rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusNotFound, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) @@ -767,10 +758,9 @@ func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) { { // Can't delete last OIDC identity payload := APIDeleteOIDCIdentityRequest{ - UserUUID: &user.UUID, - Issuer: fakeOIDCProvider1.Config.Issuer, + Issuer: fakeOIDCProvider1.Config.Issuer, } - rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken) + rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user.UUID+"/oidc-identities", payload, nil, &user.APIToken) assert.Equal(t, http.StatusBadRequest, rec.Code) var apiError APIError assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError)) diff --git a/swagger.json b/assets/swagger.json similarity index 81% rename from swagger.json rename to assets/swagger.json index d6104e2..b97f5ba 100644 --- a/swagger.json +++ b/assets/swagger.json @@ -16,7 +16,7 @@ "paths": { "/drasl/api/v2/challenge-skin": { "get": { - "description": "Get a challenge skin and challenge token for a username, for registration purposes. See the `RequireSkinVerification` configuration option.", + "description": "Get a challenge skin and challenge token for a player name for registration or player creation purposes. See the `ImportExistingPlayer.RequireSkinVerification` configuration option.", "consumes": [ "application/json" ], @@ -28,6 +28,17 @@ "players" ], "summary": "Get a challenge skin/token", + "parameters": [ + { + "description": "Player name for the challenge skin", + "name": "APIGetChallengeSkinRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APIGetChallengeSkinRequest" + } + } + ], "responses": { "200": { "description": "OK", @@ -35,6 +46,12 @@ "$ref": "#/definitions/main.APIChallenge" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -164,7 +181,7 @@ }, "/drasl/api/v2/login": { "post": { - "description": "Get a token for login credentials.", + "description": "Authenticate with username and password and receive an API token. Can be called without an API token.", "consumes": [ "application/json" ], @@ -172,10 +189,20 @@ "application/json" ], "tags": [ - "users", - "auth" + "users" + ], + "summary": "Authenticate and receive an API token", + "parameters": [ + { + "description": "Drasl username and password", + "name": "APILoginRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APILoginRequest" + } + } ], - "summary": "Get a token", "responses": { "200": { "description": "OK", @@ -216,90 +243,6 @@ } } }, - "/drasl/api/v2/oidc-identities": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Link an OIDC identity to a user", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/main.APIError" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Unlink an OIDC identity from a user", - "responses": { - "204": { - "description": "No Content" - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/main.APIError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/main.APIError" - } - } - } - } - }, "/drasl/api/v2/players": { "get": { "description": "Get details of all players. Requires admin privileges.", @@ -344,7 +287,7 @@ } }, "post": { - "description": "Create a new player", + "description": "Create a new player for an existing Drasl user.", "consumes": [ "application/json" ], @@ -462,7 +405,7 @@ } }, "delete": { - "description": "Delete a player. This action cannot be undone.", + "description": "Delete a player. This action cannot be undone. Requires admin privileges unless you own the player.", "consumes": [ "application/json" ], @@ -584,7 +527,7 @@ }, "/drasl/api/v2/user": { "get": { - "description": "Get details of your own account", + "description": "Get details of a user, either the calling user (GET /user) or the user with the specified UUID (GET /users/{uuid}). Getting details of another user requires admin privileges.", "consumes": [ "application/json" ], @@ -594,7 +537,7 @@ "tags": [ "users" ], - "summary": "Get own account", + "summary": "Get user details", "responses": { "200": { "description": "OK", @@ -602,14 +545,26 @@ "$ref": "#/definitions/main.APIUser" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/main.APIError" } }, - "429": { - "description": "Too Many Requests", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/main.APIError" } @@ -623,7 +578,7 @@ } }, "delete": { - "description": "Delete your own account. This action cannot be undone.", + "description": "Delete a user, either the calling user (DELETE /user) or the user with the specified UUID (DELETE /users/{uuid}). This action cannot be undone. Deleting another user requires admin privileges.", "consumes": [ "application/json" ], @@ -633,19 +588,19 @@ "tags": [ "users" ], - "summary": "Delete own account", + "summary": "Delete user", "responses": { "204": { "description": "No Content" }, - "401": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "schema": { "$ref": "#/definitions/main.APIError" } }, - "429": { - "description": "Too Many Requests", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/main.APIError" } @@ -659,7 +614,7 @@ } }, "patch": { - "description": "Update details of your own account.", + "description": "Update an existing user, either the calling user (PATCH /user) or the user with the specified UUID (PATCH /users/{uuid}). Updating another user requires admin privileges.", "consumes": [ "application/json" ], @@ -669,7 +624,7 @@ "tags": [ "users" ], - "summary": "Update own account", + "summary": "Update a user", "parameters": [ { "description": "New properties of the user", @@ -721,6 +676,127 @@ } } }, + "/drasl/api/v2/user/oidc-identities": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Link an OIDC identity to a user", + "parameters": [ + { + "description": "OIDC identity to link to the user", + "name": "APICreateOIDCIdentityRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APICreateOIDCIdentityRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.APIOIDCIdentity" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.APIError" + } + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Unlink an OIDC identity from a user", + "parameters": [ + { + "description": "Issuer of the OIDC provider to unlink from the user", + "name": "APIDeleteOIDCIdentityRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APIDeleteOIDCIdentityRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.APIError" + } + } + } + } + }, "/drasl/api/v2/users": { "get": { "description": "Get details of all users. Requires admin privileges.", @@ -765,7 +841,7 @@ } }, "post": { - "description": "Create a new user. Requires admin privileges.", + "description": "Register and create a new user. Can be called without an API token.", "consumes": [ "application/json" ], @@ -829,7 +905,7 @@ }, "/drasl/api/v2/users/{uuid}": { "get": { - "description": "Get details of a user by their UUID. Requires admin privileges.", + "description": "Get details of a user, either the calling user (GET /user) or the user with the specified UUID (GET /users/{uuid}). Getting details of another user requires admin privileges.", "consumes": [ "application/json" ], @@ -839,7 +915,7 @@ "tags": [ "users" ], - "summary": "Get user by UUID", + "summary": "Get user details", "parameters": [ { "type": "string", @@ -889,7 +965,7 @@ } }, "delete": { - "description": "Delete a user. This action cannot be undone. Requires admin privileges.", + "description": "Delete a user, either the calling user (DELETE /user) or the user with the specified UUID (DELETE /users/{uuid}). This action cannot be undone. Deleting another user requires admin privileges.", "consumes": [ "application/json" ], @@ -934,7 +1010,7 @@ } }, "patch": { - "description": "Update an existing user. Requires admin privileges.", + "description": "Update an existing user, either the calling user (PATCH /user) or the user with the specified UUID (PATCH /users/{uuid}). Updating another user requires admin privileges.", "consumes": [ "application/json" ], @@ -988,6 +1064,147 @@ "$ref": "#/definitions/main.APIError" } }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.APIError" + } + } + } + } + }, + "/drasl/api/v2/users/{uuid}/oidc-identities": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Link an OIDC identity to a user", + "parameters": [ + { + "type": "string", + "description": "User UUID", + "name": "uuid", + "in": "path", + "required": true + }, + { + "description": "OIDC identity to link to the user", + "name": "APICreateOIDCIdentityRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APICreateOIDCIdentityRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.APIOIDCIdentity" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.APIError" + } + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Unlink an OIDC identity from a user", + "parameters": [ + { + "type": "string", + "description": "User UUID", + "name": "uuid", + "in": "path", + "required": true + }, + { + "description": "Issuer of the OIDC provider to unlink from the user", + "name": "APIDeleteOIDCIdentityRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.APIDeleteOIDCIdentityRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/main.APIError" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1005,12 +1222,25 @@ "challengeSkinBase64": { "description": "Base64-encoded skin PNG. Example value truncated for brevity.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..." }, "challengeToken": { "description": "Challenge token that must be passed when registering with a challenge skin", "type": "string", - "example": "414cc23d6eebee3b17a453d6b9800be3e5a4627fd3b0ee54d7c37d03b2596e44" + "example": "iK1B2FzLc5fMP94VmUR3KC" + } + } + }, + "main.APICreateOIDCIdentityRequest": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "example": "https://idm.example.com/oauth2/openid/drasl" + }, + "subject": { + "type": "string", + "example": "f85f8c18-9bdf-49ad-a76e-719f9ba3ed25" } } }, @@ -1020,7 +1250,7 @@ "capeBase64": { "description": "Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..." }, "capeUrl": { "description": "Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.", @@ -1030,7 +1260,7 @@ "challengeToken": { "description": "Challenge token to use when verifying ownership of another player. Call /drasl/api/v2/challenge-skin first to get a skin and token. See `RequireSkinVerification` in configuration.md.", "type": "string", - "example": "a484528c86725b7b5ac3b47e2f973efd" + "example": "iK1B2FzLc5fMP94VmUR3KC" }, "chosenUuid": { "description": "Optional. Specify a UUID for the new player. If omitted, a random UUID will be generated.", @@ -1055,7 +1285,7 @@ "skinBase64": { "description": "Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..." }, "skinModel": { "description": "Skin model. Either \"classic\" or \"slim\". If omitted, `\"classic\"` will be assumed.", @@ -1080,7 +1310,7 @@ "capeBase64": { "description": "Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..." }, "capeUrl": { "description": "Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.", @@ -1151,7 +1381,7 @@ "skinBase64": { "description": "Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..." }, "skinModel": { "description": "Skin model. Either \"classic\" or \"slim\". If omitted, `\"classic\"` will be assumed.", @@ -1188,6 +1418,15 @@ } } }, + "main.APIDeleteOIDCIdentityRequest": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "example": "https://idm.example.com/oauth2/openid/drasl" + } + } + }, "main.APIError": { "type": "object", "properties": { @@ -1197,6 +1436,15 @@ } } }, + "main.APIGetChallengeSkinRequest": { + "type": "object", + "properties": { + "playerName": { + "type": "string", + "example": "Notch" + } + } + }, "main.APIInvite": { "type": "object", "properties": { @@ -1217,10 +1465,23 @@ } } }, + "main.APILoginRequest": { + "type": "object", + "properties": { + "password": { + "type": "string", + "example": "hunter2" + }, + "username": { + "type": "string", + "example": "Notch" + } + } + }, "main.APILoginResponse": { "type": "object", "properties": { - "token": { + "apiToken": { "description": "An API token for the user", "type": "string", "example": "Bq608AtLeG7emJOdvXHYxL" @@ -1330,7 +1591,7 @@ "capeBase64": { "description": "Optional. Base64-encoded cape PNG. Example value truncated for brevity.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf..." }, "capeUrl": { "description": "Optional. URL to cape file", @@ -1356,8 +1617,9 @@ "example": "MyPlayerName" }, "skinBase64": { + "description": "Optional. Base64-encoded skin PNG. Example value truncated for brevity.", "type": "string", - "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI" + "example": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI..." }, "skinModel": { "description": "Optional. New skin model. Either \"classic\" or \"slim\".", @@ -1402,12 +1664,12 @@ "resetApiToken": { "description": "Pass `true` to reset the user's API token", "type": "boolean", - "example": true + "example": false }, "resetMinecraftToken": { "description": "Pass `true` to reset the user's Minecraft token", "type": "boolean", - "example": true + "example": false } } }, @@ -1460,4 +1722,4 @@ } } } -} \ No newline at end of file +} diff --git a/build_config.go b/build_config.go index c77b52e..3b0fd17 100644 --- a/build_config.go +++ b/build_config.go @@ -5,6 +5,7 @@ package main const VERSION = "3.0.0" const REPOSITORY_URL = "https://github.com/unmojang/drasl" +const SWAGGER_UI_URL = "https://doc.drasl.unmojang.org" const LICENSE = "GPLv3" const LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.en.html" diff --git a/common.go b/common.go index 2625f83..22198ac 100644 --- a/common.go +++ b/common.go @@ -110,6 +110,7 @@ type ConstantsType struct { License string LicenseURL string RepositoryURL string + SwaggerUIURL string } var Constants = &ConstantsType{ @@ -122,6 +123,7 @@ var Constants = &ConstantsType{ License: LICENSE, LicenseURL: LICENSE_URL, RepositoryURL: REPOSITORY_URL, + SwaggerUIURL: SWAGGER_UI_URL, } func MakeRequestCacheKey(url string, method string, body []byte) []byte { diff --git a/main.go b/main.go index e1df96a..7d88eb8 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ var bodyDump = middleware.BodyDump(func(c echo.Context, reqBody, resBody []byte) type App struct { FrontEndURL string PublicURL string + APIURL string AuthURL string AccountURL string ServicesURL string @@ -155,6 +156,17 @@ func (app *App) MakeServer() *echo.Echo { limit := fmt.Sprintf("%dKIB", app.Config.BodyLimit.SizeLimitKiB) e.Use(middleware.BodyLimit(limit)) } + + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + Skipper: func(c echo.Context) bool { + return !Contains([]string{ + DRASL_API_PREFIX + "/swagger.json", + DRASL_API_PREFIX + "/openapi.json", + }, c.Path()) + }, + })) + if len(app.Config.CORSAllowOrigins) > 0 { e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: app.Config.CORSAllowOrigins, @@ -201,29 +213,39 @@ func (app *App) MakeServer() *echo.Echo { e.Static("/web/texture/skin", path.Join(app.Config.StateDirectory, "skin")) // Drasl API - e.DELETE(DRASL_API_PREFIX+"/invites/:code", app.APIDeleteInvite()) - e.DELETE(DRASL_API_PREFIX+"/oidc-identities", app.APIDeleteOIDCIdentity()) - e.DELETE(DRASL_API_PREFIX+"/players/:uuid", app.APIDeletePlayer()) - e.DELETE(DRASL_API_PREFIX+"/user", app.APIDeleteSelf()) - e.DELETE(DRASL_API_PREFIX+"/users/:uuid", app.APIDeleteUser()) + apiSwagger := app.APISwagger() + e.GET(DRASL_API_PREFIX+"/swagger.json", apiSwagger) + e.GET(DRASL_API_PREFIX+"/openapi.json", apiSwagger) + apiDeleteUser := app.APIDeleteUser() + e.DELETE(DRASL_API_PREFIX+"/invites/:code", app.APIDeleteInvite()) + e.DELETE(DRASL_API_PREFIX+"/players/:uuid", app.APIDeletePlayer()) + e.DELETE(DRASL_API_PREFIX+"/user", apiDeleteUser) + e.DELETE(DRASL_API_PREFIX+"/user/oidc-identities", app.APIDeleteOIDCIdentity()) + e.DELETE(DRASL_API_PREFIX+"/users/:uuid", apiDeleteUser) + e.DELETE(DRASL_API_PREFIX+"/users/:uuid/oidc-identities", app.APIDeleteOIDCIdentity()) + + apiGetUser := app.APIGetUser() e.GET(DRASL_API_PREFIX+"/challenge-skin", app.APIGetChallengeSkin()) e.GET(DRASL_API_PREFIX+"/invites", app.APIGetInvites()) e.GET(DRASL_API_PREFIX+"/players", app.APIGetPlayers()) e.GET(DRASL_API_PREFIX+"/players/:uuid", app.APIGetPlayer()) - e.GET(DRASL_API_PREFIX+"/user", app.APIGetSelf()) + e.GET(DRASL_API_PREFIX+"/user", apiGetUser) e.GET(DRASL_API_PREFIX+"/users", app.APIGetUsers()) - e.GET(DRASL_API_PREFIX+"/users/:uuid", app.APIGetUser()) + e.GET(DRASL_API_PREFIX+"/users/:uuid", apiGetUser) + apiUpdateUser := app.APIUpdateUser() e.PATCH(DRASL_API_PREFIX+"/players/:uuid", app.APIUpdatePlayer()) - e.PATCH(DRASL_API_PREFIX+"/user", app.APIUpdateSelf()) - e.PATCH(DRASL_API_PREFIX+"/users/:uuid", app.APIUpdateUser()) + e.PATCH(DRASL_API_PREFIX+"/user", apiUpdateUser) + e.PATCH(DRASL_API_PREFIX+"/users/:uuid", apiUpdateUser) + apiCreateOIDCIdentity := app.APICreateOIDCIdentity() e.POST(DRASL_API_PREFIX+"/invites", app.APICreateInvite()) e.POST(DRASL_API_PREFIX+"/login", app.APILogin()) - e.POST(DRASL_API_PREFIX+"/oidc-identities", app.APICreateOIDCIdentity()) e.POST(DRASL_API_PREFIX+"/players", app.APICreatePlayer()) + e.POST(DRASL_API_PREFIX+"/user/oidc-identities", apiCreateOIDCIdentity) e.POST(DRASL_API_PREFIX+"/users", app.APICreateUser()) + e.POST(DRASL_API_PREFIX+"/users/:uuid/oidc-identities", apiCreateOIDCIdentity) // authlib-injector e.GET("/authlib-injector", AuthlibInjectorRoot(app)) @@ -511,6 +533,7 @@ func setup(config *Config) *App { ServicesURL: Unwrap(url.JoinPath(config.BaseURL, "services")), SessionURL: Unwrap(url.JoinPath(config.BaseURL, "session")), AuthlibInjectorURL: Unwrap(url.JoinPath(config.BaseURL, "authlib-injector")), + APIURL: Unwrap(url.JoinPath(config.BaseURL, DRASL_API_PREFIX)), VerificationSkinTemplate: verificationSkinTemplate, OIDCProvidersByName: oidcProvidersByName, OIDCProvidersByIssuer: oidcProvidersByIssuer, diff --git a/player.go b/player.go index a6827d4..94246fa 100644 --- a/player.go +++ b/player.go @@ -505,7 +505,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P } func MakeChallengeToken() (string, error) { - return RandomHex(16) + return RandomBase62(16) } func (app *App) GetChallenge(playerName string, token string) []byte { diff --git a/user.go b/user.go index 2dac108..e959349 100644 --- a/user.go +++ b/user.go @@ -433,7 +433,7 @@ func (app *App) UpdateUser( callerIsAdmin := caller.IsAdmin if user.UUID != caller.UUID && !callerIsAdmin { - return User{}, NewBadRequestUserError("You are not an admin.") + return User{}, NewBadRequestUserError("You are not authorized to update that user.") } if password != nil { diff --git a/view/user.tmpl b/view/user.tmpl index 708e45d..23f8c2e 100644 --- a/view/user.tmpl +++ b/view/user.tmpl @@ -247,6 +247,9 @@
+ {{ if ne .App.Constants.SwaggerUIURL "" }}
+ See the {{ .App.Config.ApplicationName }} API documentation.
+ {{ end }}