Lots of API cleanup, link to Swagger API docs

This commit is contained in:
Evan Goode 2025-03-22 23:05:14 -04:00
parent 32b8546276
commit 5252317a53
10 changed files with 657 additions and 396 deletions

View File

@ -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

404
api.go
View File

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

View File

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

View File

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

View File

@ -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"

View File

@ -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 {

43
main.go
View File

@ -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,

View File

@ -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 {

View File

@ -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 {

View File

@ -247,6 +247,9 @@
</p>
<p>
<label for="apiToken">API Token</label><br />
{{ if ne .App.Constants.SwaggerUIURL "" }}
<small>See the <a href="{{ .App.Constants.SwaggerUIURL }}/?url={{ .App.APIURL }}/swagger.json">{{ .App.Config.ApplicationName }} API documentation</a>.</small><br />
{{ end }}
<input
type="text"
name="apiToken"