Make multiple profiles usable from web front end

This commit is contained in:
Evan Goode 2024-10-12 18:43:28 -04:00
parent f58ce99eae
commit 738d80538f
18 changed files with 1144 additions and 645 deletions

View File

@ -42,6 +42,8 @@ func NewBadRequestUserError(message string, args ...interface{}) error {
}
type ConstantsType struct {
MaxPlayerCountUseDefault int
MaxPlayerCountUnlimited int
ConfigDirectory string
MaxPlayerNameLength int
MaxUsernameLength int
@ -52,6 +54,8 @@ type ConstantsType struct {
}
var Constants = &ConstantsType{
MaxPlayerCountUseDefault: -2,
MaxPlayerCountUnlimited: -1,
MaxUsernameLength: 16,
MaxPlayerNameLength: 16,
ConfigDirectory: DEFAULT_CONFIG_DIRECTORY,
@ -462,7 +466,7 @@ func (app *App) DeleteSkinIfUnused(hash *string) error {
var inUse bool
err := app.DB.Model(User{}).
err := app.DB.Model(Player{}).
Select("count(*) > 0").
Where("skin_hash = ?", *hash).
Find(&inUse).
@ -493,7 +497,7 @@ func (app *App) DeleteCapeIfUnused(hash *string) error {
var inUse bool
err := app.DB.Model(User{}).
err := app.DB.Model(Player{}).
Select("count(*) > 0").
Where("cape_hash = ?", *hash).
Find(&inUse).

View File

@ -71,6 +71,7 @@ type Config struct {
DataDirectory string
DefaultAdmins []string
DefaultPreferredLanguage string
DefaultMaxPlayerCount int
Domain string
EnableBackgroundEffect bool
EnableFooter bool
@ -117,6 +118,7 @@ func DefaultConfig() Config {
DataDirectory: DEFAULT_DATA_DIRECTORY,
DefaultAdmins: []string{},
DefaultPreferredLanguage: "en",
DefaultMaxPlayerCount: 1,
Domain: "",
EnableBackgroundEffect: true,
EnableFooter: true,
@ -178,6 +180,9 @@ func CleanConfig(config *Config) error {
if _, err := os.Open(config.DataDirectory); err != nil {
return fmt.Errorf("Couldn't open DataDirectory: %s", err)
}
if config.DefaultMaxPlayerCount < 0 {
return errors.New("DefaultMaxPlayerCount must be >= 0")
}
if config.RegistrationExistingPlayer.Allow {
if config.RegistrationExistingPlayer.Nickname == "" {
return errors.New("RegistrationExistingPlayer.Nickname must be set")

15
db.go
View File

@ -124,10 +124,12 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
userVersion = CURRENT_USER_VERSION
}
err := db.Transaction(func(tx *gorm.DB) error {
if userVersion < CURRENT_USER_VERSION {
log.Printf("Started migration of database version %d to version %d", userVersion, CURRENT_USER_VERSION)
initialUserVersion := userVersion
if initialUserVersion < CURRENT_USER_VERSION {
log.Printf("Started migration of database version %d to %d", userVersion, CURRENT_USER_VERSION)
}
err := db.Transaction(func(tx *gorm.DB) error {
if userVersion == 0 {
// Version 0 to 1
// Add User.OfflineUUID
@ -242,6 +244,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
APIToken: v3User.APIToken,
PreferredLanguage: v3User.PreferredLanguage,
Players: []Player{player},
MaxPlayerCount: Constants.MaxPlayerCountUseDefault,
}
user.Players = append(user.Players, player)
users = append(users, user)
@ -249,6 +252,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(&users).Error; err != nil {
return err
}
userVersion += 1
}
err := tx.AutoMigrate(&User{})
@ -277,10 +281,13 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
return nil
})
if err != nil {
return err
}
if initialUserVersion < CURRENT_USER_VERSION {
log.Printf("Finished migration from version %d to %d", initialUserVersion, userVersion)
}
return nil
}

View File

@ -19,6 +19,7 @@ Other available options:
- `DataDirectory`: directory where Drasl's static assets are installed. String. Default value: `"/usr/share/drasl"`.
- `ListenAddress`: IP address and port to listen on. Depending on how you configure your reverse proxy and whether you run Drasl in a container, you should consider setting the listen address to `"127.0.0.1:25585"` to ensure Drasl is only accessible through the reverse proxy. If your reverse proxy is unable to connect to Drasl, try setting this back to the default value. String. Default value: `"0.0.0.0:25585"`.
- `DefaultAdmins`: Usernames of the instance's permanent admins. Admin rights can be granted to other accounts using the web UI, but admins defined via `DefaultAdmins` cannot be demoted unless they are removed from the config file. Array of strings. Default value: `[]`.
- `DefaultMaxPlayerCount`: Number of players each user is allowed to create by default. Admins can increase or decrease each user's individual limit. Use `-1` to allow creating an unlimited number of players. Integer. Default value: `1`.
- `EnableBackgroundEffect`: Whether to enable the 3D background animation in the web UI. Boolean. Default value: `true`.
- `EnableFooter`: Whether to enable the page footer in the web UI. Boolean. Default value: `true`.
- `[RateLimit]`: Rate-limit requests per IP address to limit abuse. Only applies to certain web UI routes, not any Yggdrasil routes. Requests for skins, capes, and web pages are also unaffected. Uses [Echo](https://echo.labstack.com)'s [rate limiter middleware](https://echo.labstack.com/middleware/rate-limiter/).

276
front.go
View File

@ -36,14 +36,15 @@ func NewTemplate(app *App) *Template {
names := []string{
"root",
"profile",
"user",
"player",
"registration",
"challenge-skin",
"challenge",
"admin",
}
funcMap := template.FuncMap{
"UserSkinURL": app.UserSkinURL,
"PlayerSkinURL": app.PlayerSkinURL,
"InviteURL": app.InviteURL,
"IsDefaultAdmin": app.IsDefaultAdmin,
}
@ -178,9 +179,10 @@ func getReturnURL(app *App, c *echo.Context) string {
if (*c).FormValue("returnUrl") != "" {
return (*c).FormValue("returnUrl")
}
if (*c).QueryParam("returnUrl") != "" {
return (*c).QueryParam("username")
}
// TODO no idea why this is here
// if (*c).QueryParam("returnUrl") != "" {
// return (*c).QueryParam("username")
// }
return app.FrontEndURL
}
@ -188,7 +190,15 @@ func getReturnURL(app *App, c *echo.Context) string {
// reference to the user
func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Context, user *User) error) func(c echo.Context) error {
return func(c echo.Context) error {
returnURL := getReturnURL(app, &c)
destination := c.Request().URL.String()
if c.Request().Method != "GET" {
destination = getReturnURL(app, &c)
}
returnURL, err := addDestination(app.FrontEndURL, destination)
if err != nil {
return err
}
cookie, err := c.Cookie("browserToken")
var user User
@ -239,6 +249,7 @@ func FrontRoot(app *App) func(c echo.Context) error {
App *App
User *User
URL string
Destination string
SuccessMessage string
WarningMessage string
ErrorMessage string
@ -249,6 +260,7 @@ func FrontRoot(app *App) func(c echo.Context) error {
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
Destination: c.QueryParam("destination"),
SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c),
@ -324,7 +336,7 @@ func FrontAdmin(app *App) func(c echo.Context) error {
return withBrowserAdmin(app, func(c echo.Context, user *User) error {
var users []User
result := app.DB.Find(&users)
result := app.DB.Preload("Players").Find(&users)
if result.Error != nil {
return result.Error
}
@ -429,35 +441,42 @@ func FrontNewInvite(app *App) func(c echo.Context) error {
})
}
// GET /drasl/profile
func FrontProfile(app *App) func(c echo.Context) error {
type profileContext struct {
// GET /drasl/user
// GET /drasl/user/:uuid
func FrontUser(app *App) func(c echo.Context) error {
type userContext struct {
App *App
User *User
URL string
SuccessMessage string
WarningMessage string
ErrorMessage string
ProfileUser *User
ProfileUserID string
TargetUser *User
TargetUserID string
SkinURL *string
CapeURL *string
AdminView bool
MaxPlayerCount int
}
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
var profileUser *User
profileUsername := c.QueryParam("user")
var targetUser *User
targetUUID := c.Param("uuid")
adminView := false
if profileUsername == "" || profileUsername == user.Username {
profileUser = user
if targetUUID == "" || targetUUID == user.UUID {
var targetUserStruct User
result := app.DB.Preload("Players").First(&targetUserStruct, "uuid = ?", user.UUID)
if result.Error != nil {
return result.Error
}
targetUser = &targetUserStruct
} else {
if !user.IsAdmin {
return NewWebError(app.FrontEndURL, "You are not an admin.")
}
var profileUserStruct User
result := app.DB.First(&profileUserStruct, "username = ?", profileUsername)
profileUser = &profileUserStruct
adminView = true
var targetUserStruct User
result := app.DB.Preload("Players").First(&targetUserStruct, "uuid = ?", targetUUID)
if result.Error != nil {
returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin")
if err != nil {
@ -465,16 +484,68 @@ func FrontProfile(app *App) func(c echo.Context) error {
}
return NewWebError(returnURL, "User not found.")
}
adminView = true
targetUser = &targetUserStruct
}
// TODO support multiple players
player := &profileUser.Players[0]
skinURL, err := app.GetSkinURL(player)
maxPlayerCount := app.GetMaxPlayerCount(targetUser)
return c.Render(http.StatusOK, "user", userContext{
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c),
TargetUser: targetUser,
// SkinURL: skinURL,
// CapeURL: capeURL,
AdminView: adminView,
MaxPlayerCount: maxPlayerCount,
})
})
}
// GET /drasl/player/:uuid
func FrontPlayer(app *App) func(c echo.Context) error {
type playerContext struct {
App *App
User *User
URL string
SuccessMessage string
WarningMessage string
ErrorMessage string
Player *Player
PlayerID string
SkinURL *string
CapeURL *string
AdminView bool
}
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
playerUUID := c.Param("uuid")
var player Player
result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
if result.Error != nil {
returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin")
if err != nil {
return err
}
capeURL, err := app.GetCapeURL(player)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return NewWebError(returnURL, "Player not found.")
}
return result.Error
}
if !user.IsAdmin && (player.User.UUID != user.UUID) {
return NewWebError(app.FrontEndURL, "You are not an admin.")
}
adminView := player.User.UUID != user.UUID
skinURL, err := app.GetSkinURL(&player)
if err != nil {
return err
}
capeURL, err := app.GetCapeURL(&player)
if err != nil {
return err
}
@ -484,15 +555,15 @@ func FrontProfile(app *App) func(c echo.Context) error {
return err
}
return c.Render(http.StatusOK, "profile", profileContext{
return c.Render(http.StatusOK, "player", playerContext{
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c),
ProfileUser: profileUser,
ProfileUserID: id,
Player: &player,
PlayerID: id,
SkinURL: skinURL,
CapeURL: capeURL,
AdminView: adminView,
@ -559,7 +630,7 @@ func FrontUpdatePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
targetUUID := nilIfEmpty(c.FormValue("uuid"))
playerUUID := c.FormValue("uuid")
playerName := nilIfEmpty(c.FormValue("playerName"))
fallbackPlayer := nilIfEmpty(c.FormValue("fallbackPlayer"))
skinModel := nilIfEmpty(c.FormValue("skinModel"))
@ -569,7 +640,7 @@ func FrontUpdatePlayer(app *App) func(c echo.Context) error {
deleteCape := c.FormValue("deleteCape") == "on"
var player Player
result := app.DB.First(&player, "uuid = ?", targetUUID)
result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
if result.Error != nil {
return NewWebError(returnURL, "Player not found.")
}
@ -646,30 +717,59 @@ func FrontLogout(app *App) func(c echo.Context) error {
})
}
// GET /challenge-skin
func FrontChallengeSkin(app *App) func(c echo.Context) error {
type challengeSkinContext struct {
const (
ChallengeActionRegister string = "register"
ChallengeActionCreatePlayer string = "create-player"
)
// GET /create-player-challenge
func FrontCreatePlayerChallenge(app *App) func(c echo.Context) error {
return frontChallenge(app, ChallengeActionCreatePlayer)
}
// GET /register-challenge
func FrontRegisterChallenge(app *App) func(c echo.Context) error {
return frontChallenge(app, ChallengeActionRegister)
}
func frontChallenge(app *App, action string) func(c echo.Context) error {
type challengeContext struct {
App *App
User *User
URL string
SuccessMessage string
WarningMessage string
ErrorMessage string
Username string
PlayerName string
RegistrationProvider string
SkinBase64 string
SkinFilename string
ChallengeToken string
InviteCode string
Action string
UserUUID *string
}
return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
var playerName string
var userUUID *string
if action == ChallengeActionRegister {
username := c.QueryParam("username")
if err := app.ValidateUsername(username); err != nil {
return NewWebError(returnURL, "Invalid username: %s", err)
}
playerName = username
} else if action == ChallengeActionCreatePlayer {
playerName = c.QueryParam("playerName")
userUUIDString := c.QueryParam("userUuid")
userUUID = &userUUIDString
}
if err := app.ValidatePlayerName(playerName); err != nil {
return NewWebError(returnURL, "Invalid player name: %s", err)
}
inviteCode := c.QueryParam("inviteCode")
@ -692,7 +792,7 @@ func FrontChallengeSkin(app *App) func(c echo.Context) error {
challengeToken = cookie.Value
}
challengeSkinBytes, err := app.GetChallengeSkin(username, challengeToken)
challengeSkinBytes, err := app.GetChallengeSkin(playerName, challengeToken)
if err != nil {
var userError *UserError
if errors.As(err, &userError) {
@ -702,25 +802,69 @@ func FrontChallengeSkin(app *App) func(c echo.Context) error {
}
skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes)
return c.Render(http.StatusOK, "challenge-skin", challengeSkinContext{
return c.Render(http.StatusOK, "challenge", challengeContext{
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c),
Username: username,
PlayerName: playerName,
SkinBase64: skinBase64,
SkinFilename: username + "-challenge.png",
SkinFilename: playerName + "-challenge.png",
ChallengeToken: challengeToken,
InviteCode: inviteCode,
Action: action,
UserUUID: userUUID,
})
})
}
// POST /create-player
func FrontCreatePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, caller *User) error {
userUUID := c.FormValue("userUuid")
playerName := c.FormValue("playerName")
chosenUUID := nilIfEmpty(c.FormValue("playerUuid"))
existingPlayer := c.FormValue("existingPlayer") == "on"
challengeToken := nilIfEmpty(c.FormValue("challengeToken"))
failureURL := getReturnURL(app, &c)
player, err := app.CreatePlayer(
caller,
userUUID,
playerName,
chosenUUID,
existingPlayer,
challengeToken,
nil, // fallbackPlayer
nil, // skinModel
nil, // skinReader
nil, // skinURL
nil, // capeReader
nil, // capeURL
)
if err != nil {
var userError *UserError
if errors.As(err, &userError) {
return &WebError{ReturnURL: failureURL, Err: userError.Err}
}
return err
}
returnURL, err := url.JoinPath(app.FrontEndURL, "web/player", player.UUID)
if err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
// POST /register
func FrontRegister(app *App) func(c echo.Context) error {
returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/profile"))
returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/user"))
return func(c echo.Context) error {
username := c.FormValue("username")
honeypot := c.FormValue("email")
@ -795,9 +939,23 @@ func FrontRegister(app *App) func(c echo.Context) error {
}
}
func addDestination(url_ string, destination string) (string, error) {
if destination == "" {
return url_, nil
} else {
urlStruct, err := url.Parse(url_)
if err != nil {
return "", err
}
query := urlStruct.Query()
query.Set("destination", destination)
urlStruct.RawQuery = query.Encode()
return urlStruct.String(), nil
}
}
// POST /login
func FrontLogin(app *App) func(c echo.Context) error {
returnURL := app.FrontEndURL + "/web/profile"
return func(c echo.Context) error {
failureURL := getReturnURL(app, &c)
@ -847,6 +1005,14 @@ func FrontLogin(app *App) func(c echo.Context) error {
user.BrowserToken = MakeNullString(&browserToken)
app.DB.Save(&user)
returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
if err != nil {
return err
}
destination := c.FormValue("destination")
if destination != "" {
returnURL = destination
}
return c.Redirect(http.StatusSeeOther, returnURL)
}
}
@ -892,3 +1058,33 @@ func FrontDeleteUser(app *App) func(c echo.Context) error {
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
// POST /delete-player
func FrontDeletePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
playerUUID := c.FormValue("uuid")
var player Player
result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return NewWebError(returnURL, "Player not found.")
}
return result.Error
}
if !user.IsAdmin && (player.User.UUID != player.User.UUID) {
return NewWebError(app.FrontEndURL, "You are not an admin.")
}
err := app.DeletePlayer(&player)
if err != nil {
return err
}
setSuccessMessage(&c, fmt.Sprintf("Player \"%s\" deleted", player.Name))
return c.Redirect(http.StatusSeeOther, returnURL)
})
}

17
main.go
View File

@ -82,6 +82,7 @@ func makeRateLimiter(app *App) echo.MiddlewareFunc {
switch c.Path() {
case "/",
"/web/delete-user",
"/web/delete-player",
"/web/login",
"/web/logout",
"/web/register",
@ -144,25 +145,31 @@ func (app *App) MakeServer() *echo.Echo {
t := NewTemplate(app)
e.Renderer = t
e.GET("/", FrontRoot(app))
e.GET("/web/manifest.webmanifest", FrontWebManifest(app))
e.GET("/web/admin", FrontAdmin(app))
e.GET("/web/challenge-skin", FrontChallengeSkin(app))
e.GET("/web/profile", FrontProfile(app))
e.GET("/web/create-player-challenge", FrontCreatePlayerChallenge(app))
e.GET("/web/manifest.webmanifest", FrontWebManifest(app))
e.GET("/web/player/:uuid", FrontPlayer(app))
e.GET("/web/register-challenge", FrontRegisterChallenge(app))
e.GET("/web/registration", FrontRegistration(app))
frontUser := FrontUser(app)
e.GET("/web/user", frontUser)
e.GET("/web/user/:uuid", frontUser)
e.POST("/web/admin/delete-invite", FrontDeleteInvite(app))
e.POST("/web/admin/new-invite", FrontNewInvite(app))
e.POST("/web/admin/update-users", FrontUpdateUsers(app))
e.POST("/web/create-player", FrontCreatePlayer(app))
e.POST("/web/delete-player", FrontDeletePlayer(app))
e.POST("/web/delete-user", FrontDeleteUser(app))
e.POST("/web/login", FrontLogin(app))
e.POST("/web/logout", FrontLogout(app))
e.POST("/web/register", FrontRegister(app))
e.POST("/web/update-user", FrontUpdateUser(app))
e.POST("/web/update-player", FrontUpdatePlayer(app))
e.POST("/web/update-user", FrontUpdateUser(app))
e.Static("/web/public", path.Join(app.Config.DataDirectory, "public"))
e.Static("/web/texture/cape", path.Join(app.Config.StateDirectory, "cape"))
e.Static("/web/texture/skin", path.Join(app.Config.StateDirectory, "skin"))
e.Static("/web/texture/default-cape", path.Join(app.Config.StateDirectory, "default-cape"))
e.Static("/web/texture/default-skin", path.Join(app.Config.StateDirectory, "default-skin"))
e.Static("/web/texture/skin", path.Join(app.Config.StateDirectory, "skin"))
// Drasl API
e.GET("/drasl/api/v1/users", app.APIGetUsers())

View File

@ -69,7 +69,7 @@ func (app *App) ValidatePlayerName(playerName string) error {
if app.TransientLoginEligible(playerName) {
return errors.New("name is reserved for transient login")
}
maxLength := app.Constants.MaxPlayerNameLength
maxLength := Constants.MaxPlayerNameLength
if playerName == "" {
return errors.New("can't be blank")
}
@ -137,7 +137,7 @@ func (app *App) ValidatePlayerNameOrUUID(player string) error {
func (app *App) TransientLoginEligible(playerName string) bool {
return app.Config.TransientUsers.Allow &&
app.TransientUsernameRegex.MatchString(playerName) &&
len(playerName) <= app.Constants.MaxPlayerNameLength
len(playerName) <= Constants.MaxPlayerNameLength
}
func (app *App) ValidatePassword(password string) error {
@ -254,7 +254,7 @@ func (app *App) InviteURL(invite *Invite) (string, error) {
return url + "?invite=" + invite.Code, nil
}
func (app *App) UserSkinURL(player *Player) (*string, error) {
func (app *App) PlayerSkinURL(player *Player) (*string, error) {
if !player.SkinHash.Valid {
return nil, nil
}
@ -345,6 +345,16 @@ func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Cli
return &client
}
func (app *App) GetMaxPlayerCount(user *User) int {
if user.IsAdmin {
return Constants.MaxPlayerCountUnlimited
}
if user.MaxPlayerCount == Constants.MaxPlayerCountUseDefault {
return app.Config.DefaultMaxPlayerCount
}
return user.MaxPlayerCount
}
type User struct {
IsAdmin bool
IsLocked bool
@ -355,7 +365,8 @@ type User struct {
BrowserToken sql.NullString `gorm:"index"`
APIToken string
PreferredLanguage string
Players []Player `gorm:"foreignKey:UserUUID"`
Players []Player
MaxPlayerCount int
}
type Player struct {
@ -369,7 +380,7 @@ type Player struct {
CapeHash sql.NullString `gorm:"index"`
ServerID sql.NullString
FallbackPlayer string
Clients []Client `gorm:"foreignKey:PlayerUUID"`
Clients []Client
User User
UserUUID string `gorm:"not null"`
}

View File

@ -69,7 +69,7 @@ func (app *App) getTexture(
func (app *App) CreatePlayer(
caller *User,
user *User,
userUUID string,
playerName string,
chosenUUID *string,
existingPlayer bool,
@ -87,6 +87,27 @@ func (app *App) CreatePlayer(
callerIsAdmin := caller.IsAdmin
if userUUID != caller.UUID && !callerIsAdmin {
return Player{}, NewBadRequestUserError("Can't create a player belonging to another user unless you're an admin.")
}
tx := app.DB.Session(&gorm.Session{FullSaveAssociations: true}).Begin()
defer tx.Rollback()
var user User
if err := tx.Preload("Players").First(&user, "uuid = ?", userUUID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Player{}, NewBadRequestUserError("User not found.")
}
return Player{}, err
}
maxPlayerCount := app.GetMaxPlayerCount(&user)
log.Println("mpc is", maxPlayerCount, "pc is", len(user.Players))
if len(user.Players) >= maxPlayerCount && !callerIsAdmin {
return Player{}, NewBadRequestUserError("You are only allowed to create %d player(s).", maxPlayerCount)
}
if err := app.ValidatePlayerName(playerName); err != nil {
return Player{}, NewBadRequestUserError("Invalid player name: %s", err)
}
@ -168,6 +189,7 @@ func (app *App) CreatePlayer(
player := Player{
UUID: playerUUID,
UserUUID: userUUID,
Clients: []Client{},
Name: playerName,
OfflineUUID: offlineUUID,
@ -178,11 +200,9 @@ func (app *App) CreatePlayer(
CreatedAt: time.Now(),
NameLastChangedAt: time.Now(),
}
user.Players = append(user.Players, player)
tx := app.DB.Begin()
defer tx.Rollback()
if err := tx.Create(&player).Error; err != nil {
if err := tx.Save(&user).Error; err != nil {
if IsErrorUniqueFailedField(err, "players.name") {
return Player{}, NewBadRequestUserError("That player name is taken.")
} else if IsErrorUniqueFailedField(err, "players.uuid") {
@ -194,10 +214,6 @@ func (app *App) CreatePlayer(
return Player{}, err
}
if err := app.DB.Preload("Players").First(&user, user.UUID).Error; err != nil {
return Player{}, err
}
if skinHash != nil {
err = app.WriteSkin(*skinHash, skinBuf)
if err != nil {
@ -241,7 +257,7 @@ func (app *App) UpdatePlayer(
}
if playerName != nil && *playerName != player.Name {
if !app.Config.AllowChangingPlayerName && !user.IsAdmin {
if !app.Config.AllowChangingPlayerName && !callerIsAdmin {
return Player{}, NewBadRequestUserError("Changing your player name is not allowed.")
}
if err := app.ValidatePlayerName(*playerName); err != nil {
@ -551,3 +567,21 @@ func (app *App) InvalidatePlayer(db *gorm.DB, player *Player) error {
result := db.Model(Client{}).Where("player_uuid = ?", player.UUID).Update("version", gorm.Expr("version + ?", 1))
return result.Error
}
func (app *App) DeletePlayer(player *Player) error {
if err := app.DB.Select("Clients").Delete(player).Error; err != nil {
return err
}
err := app.DeleteSkinIfUnused(UnmakeNullString(&player.SkinHash))
if err != nil {
return err
}
err = app.DeleteCapeIfUnused(UnmakeNullString(&player.CapeHash))
if err != nil {
return err
}
return nil
}

18
user.go
View File

@ -125,6 +125,19 @@ func (app *App) CreateUser(
if err != nil {
return User{}, err
}
if chosenUUID == nil {
playerUUID = uuid.New().String()
} else {
if !app.Config.RegistrationNewPlayer.AllowChoosingUUID && !callerIsAdmin {
return User{}, NewBadRequestUserError("Choosing a UUID is not allowed.")
}
chosenUUIDStruct, err := uuid.Parse(*chosenUUID)
if err != nil {
return User{}, NewBadRequestUserError("Invalid UUID: %s", err)
}
playerUUID = chosenUUIDStruct.String()
}
}
passwordSalt := make([]byte, 16)
@ -160,6 +173,7 @@ func (app *App) CreateUser(
PasswordHash: passwordHash,
PreferredLanguage: app.Config.DefaultPreferredLanguage,
APIToken: apiToken,
MaxPlayerCount: Constants.MaxPlayerCountUseDefault,
}
// Player
@ -313,6 +327,10 @@ func (app *App) UpdateUser(
user.APIToken = apiToken
}
if err := app.DB.Save(&user).Error; err != nil {
return User{}, err
}
return user, nil
}

View File

@ -82,8 +82,8 @@
<table>
<thead>
<tr>
<td colspan="2">Profile</td>
<td>Player Name</td>
<td colspan="2">User</td>
<td>Players</td>
<td>Admin</td>
<td>Locked</td>
<td>Delete Account</td>
@ -95,16 +95,25 @@
<td style="width: 30px">
<div
class="list-profile-picture"
style="background-image: url({{ PlayerSkinURL $user }});"
{{/*style="background-image: url({{ PlayerSkinURL $user }});"*/}}
></div>
</td>
<td>
<a
href="{{ $.App.FrontEndURL }}/web/profile?user={{ $user.Username }}"
href="{{ $.App.FrontEndURL }}/web/user/{{ $user.UUID }}"
>{{ $user.Username }}</a
>
</td>
<td>{{ $user.PlayerName }}</td>
<td>
{{ if eq (len $user.Players) 1 }}
{{ with $player := index $user.Players 0 }}
<a href="{{ $.App.FrontEndURL }}/web/player/{{ $player.UUID }}">{{ $player.Name }}</a>
{{ end }}
{{ else if gt (len $user.Players) 1 }}
{{ len $user.Players }} players
{{ end }}
</td>
{{/*<td>{{ $user.PlayerName }}</td>*/}}
<td>
<input
name="admin-{{ $user.Username }}"
@ -148,7 +157,7 @@
</table>
<p style="text-align: center">
<input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="submit" value="Save Changes" />
<input type="submit" value="Save changes" />
</p>
</form>

View File

@ -1,57 +0,0 @@
{{ template "layout" . }}
{{ define "content" }}
{{ template "header" . }}
<p>
We need to verify that you own the
{{ .App.Config.RegistrationExistingPlayer.Nickname }} account
"{{ .Username }}" before you register its UUID.
</p>
{{/* prettier-ignore-start */}}
<p>
Download this image and set it as your skin on your
{{ .App.Config.RegistrationExistingPlayer.Nickname }}
account{{ if .App.Config.RegistrationExistingPlayer.SetSkinURL }}, <a target="_blank" href="{{ .App.Config.RegistrationExistingPlayer.SetSkinURL }}">here</a>{{ end }}.
</p>
{{/* prettier-ignore-end */}}
<div style="text-align: center">
<img
src="data:image/png;base64,{{ .SkinBase64 }}"
width="256"
height="256"
style="image-rendering: pixelated; width: 256px"
alt="{{ .App.Config.ApplicationName }} verification skin"
/>
<p>
<a
download="{{ .SkinFilename }}"
href="data:image/png;base64,{{ .SkinBase64 }}"
>Download skin</a
>
</p>
</div>
<p>
When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
"Register".
</p>
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
<input
type="text"
name="username"
value="{{ .Username }}"
required
hidden
/>
<input type="password" name="password" placeholder="Password" required />
<input type="checkbox" name="existingPlayer" checked hidden />
<input hidden name="challengeToken" value="{{ .ChallengeToken }}" />
<input hidden name="inviteCode" value="{{ .InviteCode }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Register" />
</form>
{{ template "footer" . }}
{{ end }}

70
view/challenge.tmpl Normal file
View File

@ -0,0 +1,70 @@
{{ template "layout" . }}
{{ define "content" }}
{{ template "header" . }}
<p>
We need to verify that you own the
{{ .App.Config.RegistrationExistingPlayer.Nickname }} account
"{{ .PlayerName }}" before you register its UUID.
</p>
<p>
Download this image and set it as your skin on your
{{ .App.Config.RegistrationExistingPlayer.Nickname }}
account{{ if .App.Config.RegistrationExistingPlayer.SetSkinURL }}, <a target="_blank" href="{{ .App.Config.RegistrationExistingPlayer.SetSkinURL }}">here</a>{{ end }}.
</p>
<div style="text-align: center">
<img
src="data:image/png;base64,{{ .SkinBase64 }}"
width="256"
height="256"
style="image-rendering: pixelated; width: 256px;"
alt="{{ .App.Config.ApplicationName }} verification skin"
/>
<p>
<a
download="{{ .SkinFilename }}"
href="data:image/png;base64,{{ .SkinBase64 }}"
>Download skin</a
>
</p>
</div>
{{ if eq .Action "register" }}
<p>
When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
"Register".
</p>
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
<input
type="text"
name="username"
value="{{ .PlayerName }}"
required
hidden
/>
<input type="password" name="password" placeholder="Password" required />
<input type="checkbox" name="existingPlayer" checked hidden />
<input hidden name="challengeToken" value="{{ .ChallengeToken }}" />
<input hidden name="inviteCode" value="{{ .InviteCode }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Register" />
</form>
{{ else if eq .Action "create-player" }}
<p>
When you are done, hit "Create player".
</p>
<form action="{{ .App.FrontEndURL }}/web/create-player" method="post">
<input hidden name="userUuid" value="{{ .UserUUID }}"/>
<input hidden name="playerName" value="{{ .PlayerName }}"/>
<input type="checkbox" name="existingPlayer" checked hidden />
<input hidden name="challengeToken" value="{{ .ChallengeToken }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Create player" />
</form>
{{ end }}
{{ template "footer" . }}
{{ end }}

View File

@ -11,21 +11,22 @@
</h1>
</div>
<div style="text-align: right">
<a href="{{ .App.FrontEndURL }}/web/registration">Register</a>
{{ if .User }}
{{ if .User.IsAdmin }}
<a href="{{ .App.FrontEndURL }}/web/admin">Admin</a>
{{ end }}
<a href="{{ .App.FrontEndURL }}/web/profile"
>{{ .User.Username }}'s&nbsp;Profile</a
<a href="{{ .App.FrontEndURL }}/web/user"
>{{ .User.Username }}'s&nbsp;account</a
>
<form
style="display: inline"
action="{{ .App.FrontEndURL }}/web/logout"
method="post"
>
<input type="submit" value="Log Out" />
<input type="submit" value="Log out" />
</form>
{{ else }}
<a href="{{ .App.FrontEndURL }}/web/registration">Register</a>
{{ end }}
</div>
</nav>

181
view/player.tmpl Normal file
View File

@ -0,0 +1,181 @@
{{ template "layout" . }}
{{ define "title" }}{{ .Player.Name }} - {{ .App.Config.ApplicationName }}{{ end }}
{{ define "content" }}
{{ template "header" . }}
<p>
{{ if .AdminView }}
<a href="{{ .App.FrontEndURL }}/web/user/{{ .Player.User.UUID }}">Back to {{ .Player.User.Username }}'s account</a>
{{ else }}
<a href="{{ .App.FrontEndURL }}/web/user">Back to your account</a>
{{ end }}
</p>
<h2 style="text-align: center;">{{ .Player.Name }}</h2>
{{/* prettier-ignore-start */}}
<h6 style="text-align: center;">{{ .Player.UUID }}<br />{{ .PlayerID }}</h6>
{{/* prettier-ignore-end */}}
{{ if .SkinURL }}
<div id="skin-container" style="height: 300px;">
<canvas id="skin-canvas"></canvas>
</div>
{{ else }}
No skin yet.
{{ end }}
<form
action="{{ .App.FrontEndURL }}/web/update-player"
method="post"
enctype="multipart/form-data"
>
{{ if or .App.Config.AllowChangingPlayerName .User.IsAdmin }}
<p>
<label for="player-name"
>Player Name (can be different from {{ if .AdminView }}{{ .Player.User.Username }}'s{{ else }}your{{ end }} {{ .App.Config.ApplicationName }} username)</label
><br />
<input
type="text"
name="playerName"
id="player-name"
value="{{ .Player.Name }}"
/>
</p>
{{ end }}
{{ if or .App.Config.AllowSkins .User.IsAdmin }}
<h4>Skin</h4>
<p>
<label for="skin-file">Upload a skin</label><br />
<input type="file" name="skinFile" id="skin-file" />
</p>
{{ if or .App.Config.AllowTextureFromURL .User.IsAdmin }}
<p>
<label for="skin-url">or instead, provide a URL to a skin</label><br />
<input
type="text"
name="skinUrl"
id="skin-url"
class="long"
placeholder="Leave blank to keep"
/>
</p>
{{ end }}
<p>
<label for="delete-skin"
>or instead, check the box to delete the current skin
</label>
<input type="checkbox" name="deleteSkin" id="delete-skin" />
</p>
<fieldset>
<legend>Skin model</legend>
<input
type="radio"
id="skin-model-classic"
name="skinModel"
value="classic"
{{ if eq .Player.SkinModel "classic" }}checked{{ end }}
/>
<label for="skin-model-classic">Classic</label>
<input
type="radio"
id="skin-model-slim"
name="skinModel"
value="slim"
{{ if eq .Player.SkinModel "slim" }}checked{{ end }}
/>
<label for="skin-model-slim">Slim</label>
</fieldset>
{{ end }}
{{ if or .App.Config.AllowCapes .User.IsAdmin }}
<h4>Cape</h4>
<p>
<label for="cape-file">Upload a cape</label><br />
<input type="file" name="capeFile" id="cape-file" />
</p>
{{ if or .App.Config.AllowTextureFromURL .User.IsAdmin }}
<p>
<label for="cape-url">or instead, provide a URL to a cape</label><br />
<input
type="text"
name="capeUrl"
id="cape-url"
class="long"
placeholder="Leave blank to keep"
/>
</p>
{{ end }}
<p>
<label for="delete-cape"
>or instead, check the box to delete the current cape
</label>
<input type="checkbox" name="deleteCape" id="delete-cape" />
</p>
{{ end }}
{{ if .App.Config.ForwardSkins }}
<p>
<label for="fallback-player">Fallback Player</label><br />
UUID or 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.<br />
<input
class="long"
type="text"
name="fallbackPlayer"
id="fallback-player"
placeholder="{{ .Player.Name }}"
value="{{ .Player.FallbackPlayer }}"
/>
</p>
{{ end }}
<input hidden name="uuid" value="{{ .Player.UUID }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<p style="text-align: center;">
<input type="submit" value="Save changes" />
</p>
</form>
<p>
<details>
<summary>Delete Player</summary>
<form
action="{{ .App.FrontEndURL }}/web/delete-player"
method="post"
onsubmit="return confirm('Are you sure? This action is irreversible.');"
>
<input hidden name="uuid" value="{{ .Player.UUID }}" />
<input
hidden
name="returnUrl"
value="{{ if .AdminView }}
{{ .App.FrontEndURL }}/web/user/{{ .Player.User.UUID }}
{{ else }}
{{ .App.FrontEndURL }}/web/user
{{ end }}"
/>
<input type="submit" value="Delete Player" />
</form>
</details>
</p>
{{ if .SkinURL }}
<script type="module">
import { skinview3d } from "{{.App.FrontEndURL}}/web/public/bundle.js"
const skinCanvas = document.getElementById("skin-canvas");
const skinViewer = new skinview3d.SkinViewer({
canvas: skinCanvas,
width: 200,
height: skinCanvas.parentElement.clientHeight,
});
skinViewer.controls.enableZoom = false;
skinViewer.loadSkin({{.SkinURL}}, {
model: "{{.Player.SkinModel}}",
});
{{if .CapeURL}}
skinViewer.loadCape({{.CapeURL}});
{{end}}
skinViewer.render();
</script>
{{ end }}
{{ template "footer" . }}
{{ end }}

View File

@ -1,487 +0,0 @@
{{ template "layout" . }}
{{ define "title" }}{{ .ProfileUser.PlayerName }}'s Profile - {{ .App.Config.ApplicationName }}{{ end }}
{{ define "content" }}
{{ template "header" . }}
<h2 style="text-align: center;">{{ .ProfileUser.PlayerName }}</h2>
{{/* prettier-ignore-start */}}
<h6 style="text-align: center;">{{ .ProfileUser.UUID }}<br />{{ .ProfileUserID }}</h6>
{{/* prettier-ignore-end */}}
{{ if .SkinURL }}
<div id="skin-container" style="height: 300px;">
<canvas id="skin-canvas"></canvas>
</div>
{{ else }}
No skin yet.
{{ end }}
<form
action="{{ .App.FrontEndURL }}/web/update"
method="post"
enctype="multipart/form-data"
>
{{ if or .App.Config.AllowChangingPlayerName .User.IsAdmin }}
<p>
<label for="player-name"
>Player Name (can be different from username)</label
><br />
<input
type="text"
name="playerName"
id="player-name"
value="{{ .ProfileUser.PlayerName }}"
/>
</p>
{{ end }}
<p>
<label for="password">Password</label><br />
<input
type="password"
name="password"
id="password"
class="long"
placeholder="Leave blank to keep"
/>
</p>
<p>
<label for="apiToken">API Token</label><br />
<input
type="text"
name="apiToken"
id="api-token"
class="long"
readonly
value="{{ .ProfileUser.APIToken }}"
/>
<br />
<label for="reset-api-token"
>check the box to reset your API token
</label>
<input type="checkbox" name="resetApiToken" id="reset-api-token" />
</p>
<p>
<label for="preferred-language"
>Preferred Language (used by Minecraft)</label
><br />
<select
name="preferredLanguage"
id="preferred-language"
value="{{ .ProfileUser.PreferredLanguage }}"
>
<option
value="sq"
{{ if eq .ProfileUser.PreferredLanguage "sq" }}selected{{ end }}
>
Albanian
</option>
<option
value="ar"
{{ if eq .ProfileUser.PreferredLanguage "ar" }}selected{{ end }}
>
Arabic
</option>
<option
value="be"
{{ if eq .ProfileUser.PreferredLanguage "be" }}selected{{ end }}
>
Belarusian
</option>
<option
value="bg"
{{ if eq .ProfileUser.PreferredLanguage "bg" }}selected{{ end }}
>
Bulgarian
</option>
<option
value="ca"
{{ if eq .ProfileUser.PreferredLanguage "ca" }}selected{{ end }}
>
Catalan
</option>
<option
value="zh"
{{ if eq .ProfileUser.PreferredLanguage "zh" }}selected{{ end }}
>
Chinese
</option>
<option
value="hr"
{{ if eq .ProfileUser.PreferredLanguage "hr" }}selected{{ end }}
>
Croatian
</option>
<option
value="cs"
{{ if eq .ProfileUser.PreferredLanguage "cs" }}selected{{ end }}
>
Czech
</option>
<option
value="da"
{{ if eq .ProfileUser.PreferredLanguage "da" }}selected{{ end }}
>
Danish
</option>
<option
value="nl"
{{ if eq .ProfileUser.PreferredLanguage "nl" }}selected{{ end }}
>
Dutch
</option>
<option
value="en"
{{ if eq .ProfileUser.PreferredLanguage "en" }}selected{{ end }}
>
English
</option>
<option
value="et"
{{ if eq .ProfileUser.PreferredLanguage "et" }}selected{{ end }}
>
Estonian
</option>
<option
value="fi"
{{ if eq .ProfileUser.PreferredLanguage "fi" }}selected{{ end }}
>
Finnish
</option>
<option
value="fr"
{{ if eq .ProfileUser.PreferredLanguage "fr" }}selected{{ end }}
>
French
</option>
<option
value="de"
{{ if eq .ProfileUser.PreferredLanguage "de" }}selected{{ end }}
>
German
</option>
<option
value="el"
{{ if eq .ProfileUser.PreferredLanguage "el" }}selected{{ end }}
>
Greek
</option>
<option
value="iw"
{{ if eq .ProfileUser.PreferredLanguage "iw" }}selected{{ end }}
>
Hebrew
</option>
<option
value="hi"
{{ if eq .ProfileUser.PreferredLanguage "hi" }}selected{{ end }}
>
Hindi
</option>
<option
value="hu"
{{ if eq .ProfileUser.PreferredLanguage "hu" }}selected{{ end }}
>
Hungarian
</option>
<option
value="is"
{{ if eq .ProfileUser.PreferredLanguage "is" }}selected{{ end }}
>
Icelandic
</option>
<option
value="in"
{{ if eq .ProfileUser.PreferredLanguage "in" }}selected{{ end }}
>
Indonesian
</option>
<option
value="ga"
{{ if eq .ProfileUser.PreferredLanguage "ga" }}selected{{ end }}
>
Irish
</option>
<option
value="it"
{{ if eq .ProfileUser.PreferredLanguage "it" }}selected{{ end }}
>
Italian
</option>
<option
value="ja"
{{ if eq .ProfileUser.PreferredLanguage "ja" }}selected{{ end }}
>
Japanese
</option>
<option
value="ko"
{{ if eq .ProfileUser.PreferredLanguage "ko" }}selected{{ end }}
>
Korean
</option>
<option
value="lv"
{{ if eq .ProfileUser.PreferredLanguage "lv" }}selected{{ end }}
>
Latvian
</option>
<option
value="lt"
{{ if eq .ProfileUser.PreferredLanguage "lt" }}selected{{ end }}
>
Lithuanian
</option>
<option
value="mk"
{{ if eq .ProfileUser.PreferredLanguage "mk" }}selected{{ end }}
>
Macedonian
</option>
<option
value="ms"
{{ if eq .ProfileUser.PreferredLanguage "ms" }}selected{{ end }}
>
Malay
</option>
<option
value="mt"
{{ if eq .ProfileUser.PreferredLanguage "mt" }}selected{{ end }}
>
Maltese
</option>
<option
value="no"
{{ if eq .ProfileUser.PreferredLanguage "no" }}selected{{ end }}
>
Norwegian
</option>
<option
value="nb"
{{ if eq .ProfileUser.PreferredLanguage "nb" }}selected{{ end }}
>
Norwegian Bokmål
</option>
<option
value="nn"
{{ if eq .ProfileUser.PreferredLanguage "nn" }}selected{{ end }}
>
Norwegian Nynorsk
</option>
<option
value="pl"
{{ if eq .ProfileUser.PreferredLanguage "pl" }}selected{{ end }}
>
Polish
</option>
<option
value="pt"
{{ if eq .ProfileUser.PreferredLanguage "pt" }}selected{{ end }}
>
Portuguese
</option>
<option
value="ro"
{{ if eq .ProfileUser.PreferredLanguage "ro" }}selected{{ end }}
>
Romanian
</option>
<option
value="ru"
{{ if eq .ProfileUser.PreferredLanguage "ru" }}selected{{ end }}
>
Russian
</option>
<option
value="sr"
{{ if eq .ProfileUser.PreferredLanguage "sr" }}selected{{ end }}
>
Serbian
</option>
<option
value="sk"
{{ if eq .ProfileUser.PreferredLanguage "sk" }}selected{{ end }}
>
Slovak
</option>
<option
value="sl"
{{ if eq .ProfileUser.PreferredLanguage "sl" }}selected{{ end }}
>
Slovenian
</option>
<option
value="es"
{{ if eq .ProfileUser.PreferredLanguage "es" }}selected{{ end }}
>
Spanish
</option>
<option
value="sv"
{{ if eq .ProfileUser.PreferredLanguage "sv" }}selected{{ end }}
>
Swedish
</option>
<option
value="th"
{{ if eq .ProfileUser.PreferredLanguage "th" }}selected{{ end }}
>
Thai
</option>
<option
value="tr"
{{ if eq .ProfileUser.PreferredLanguage "tr" }}selected{{ end }}
>
Turkish
</option>
<option
value="uk"
{{ if eq .ProfileUser.PreferredLanguage "uk" }}selected{{ end }}
>
Ukrainian
</option>
<option
value="vi"
{{ if eq .ProfileUser.PreferredLanguage "vi" }}selected{{ end }}
>
Vietnamese
</option>
</select>
</p>
{{ if or .App.Config.AllowSkins .User.IsAdmin }}
<h4>Skin</h4>
<p>
<label for="skin-file">Upload a skin</label><br />
<input type="file" name="skinFile" id="skin-file" />
</p>
{{ if or .App.Config.AllowTextureFromURL .User.IsAdmin }}
<p>
<label for="skin-url">or instead, provide a URL to a skin</label><br />
<input
type="text"
name="skinUrl"
id="skin-url"
class="long"
placeholder="Leave blank to keep"
/>
</p>
{{ end }}
<p>
<label for="delete-skin"
>or instead, check the box to delete your current skin
</label>
<input type="checkbox" name="deleteSkin" id="delete-skin" />
</p>
<fieldset>
<legend>Skin model</legend>
<input
type="radio"
id="skin-model-classic"
name="skinModel"
value="classic"
{{ if eq .ProfileUser.SkinModel "classic" }}checked{{ end }}
/>
<label for="skin-model-classic">Classic</label>
<input
type="radio"
id="skin-model-slim"
name="skinModel"
value="slim"
{{ if eq .ProfileUser.SkinModel "slim" }}checked{{ end }}
/>
<label for="skin-model-slim">Slim</label>
</fieldset>
{{ end }}
{{ if or .App.Config.AllowCapes .User.IsAdmin }}
<h4>Cape</h4>
<p>
<label for="cape-file">Upload a cape</label><br />
<input type="file" name="capeFile" id="cape-file" />
</p>
{{ if or .App.Config.AllowTextureFromURL .User.IsAdmin }}
<p>
<label for="cape-url">or instead, provide a URL to a cape</label><br />
<input
type="text"
name="capeUrl"
id="cape-url"
class="long"
placeholder="Leave blank to keep"
/>
</p>
{{ end }}
<p>
<label for="delete-cape"
>or instead, check the box to delete your current cape
</label>
<input type="checkbox" name="deleteCape" id="delete-cape" />
</p>
{{ end }}
{{ if .App.Config.ForwardSkins }}
<p>
<label for="fallback-player">Fallback Player</label><br />
UUID or 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.<br />
<input
class="long"
type="text"
name="fallbackPlayer"
id="fallback-player"
placeholder="{{ .ProfileUser.PlayerName }}"
value="{{ .ProfileUser.FallbackPlayer }}"
/>
</p>
{{ end }}
<input hidden name="uuid" value="{{ .ProfileUser.UUID }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<p style="text-align: center;">
<input type="submit" value="Save Changes" />
</p>
</form>
<p>
<details>
<summary>Delete Account</summary>
<form
action="{{ .App.FrontEndURL }}/web/delete-user"
method="post"
onsubmit="return confirm('Are you sure? This action is irreversible.');"
>
<input hidden name="uuid" value="{{ .ProfileUser.UUID }}" />
<input
hidden
name="returnUrl"
value="{{ if .AdminView }}
{{ .App.FrontEndURL }}/web/admin
{{ else }}
{{ .App.FrontEndURL }}
{{ end }}"
/>
<input type="submit" value="🗙 Delete Account" />
</form>
</details>
</p>
{{ if .SkinURL }}
<script type="module">
import { skinview3d } from "{{.App.FrontEndURL}}/web/public/bundle.js"
const skinCanvas = document.getElementById("skin-canvas");
const skinViewer = new skinview3d.SkinViewer({
canvas: skinCanvas,
width: 200,
height: skinCanvas.parentElement.clientHeight,
});
skinViewer.controls.enableZoom = false;
skinViewer.loadSkin({{.SkinURL}}, {
model: "{{.ProfileUser.SkinModel}}",
});
{{if .CapeURL}}
skinViewer.loadCape({{.CapeURL}});
{{end}}
skinViewer.render();
</script>
{{ end }}
{{ template "footer" . }}
{{ end }}

View File

@ -78,11 +78,11 @@
{{ if .InviteCode }}
<p><em>Using invite code {{ .InviteCode }}</em></p>
{{ end }}
<form action="{{ .App.FrontEndURL }}/web/challenge-skin" method="get">
<form action="{{ .App.FrontEndURL }}/web/register-challenge" method="get">
<input
type="text"
name="username"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player Name"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
maxlength="{{ .App.Constants.MaxUsernameLength }}"
required
/>
@ -104,7 +104,7 @@
<input
type="text"
name="username"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player Name"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
maxlength="{{ .App.Constants.MaxUsernameLength }}"
required
/>

View File

@ -7,6 +7,8 @@
<h3>Log in</h3>
<form action="{{ .App.FrontEndURL }}/web/login" method="post">
<input type="text" name="username" placeholder="Username" required />
<input hidden name="returnUrl" value="{{ .URL }}" />
<input hidden name="destination" value="{{ .Destination }}" />
<input
class="long"
type="password"
@ -103,9 +105,7 @@
-Dminecraft.api.auth.host={{ .App.AuthURL }}
-Dminecraft.api.account.host={{ .App.AccountURL }}
-Dminecraft.api.session.host={{ .App.SessionURL }}
-Dminecraft.api.services.host={{ .App.ServicesURL }}
</pre
>
-Dminecraft.api.services.host={{ .App.ServicesURL }}</pre>
For example, the full command you use to start the server might be:
<pre style="word-wrap: break-word; white-space: pre-wrap; overflow-x: auto">
@ -115,8 +115,7 @@ java -Xmx1024M -Xms1024M \
-Dminecraft.api.account.host={{ .App.AccountURL }} \
-Dminecraft.api.session.host={{ .App.SessionURL }} \
-Dminecraft.api.services.host={{ .App.ServicesURL }} \
-jar server.jar nogui</pre
>
-jar server.jar nogui</pre>
<h4>Minecraft 1.15.2 and earlier</h4>

500
view/user.tmpl Normal file
View File

@ -0,0 +1,500 @@
{{ template "layout" . }}
{{ define "title" }}{{ .TargetUser.Username }}'s Account - {{ .App.Config.ApplicationName }}{{ end }}
{{ define "content" }}
{{ template "header" . }}
<h2 style="text-align: center;">{{ .TargetUser.Username }}</h2>
<div style="display: none">
{{ range $player := .TargetUser.Players }}
<form
id="delete-{{ $player.UUID }}"
action="{{ $.App.FrontEndURL }}/web/delete-player"
method="post"
onsubmit="return confirm('Are you sure? This action is irreversible.');"
>
<input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="text" name="uuid" value="{{ $player.UUID }}" />
</form>
{{ end }}
</div>
<h3>{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} players</h3>
{{ if .TargetUser.Players }}
<table>
<thead>
<tr>
<td colspan="2">Player</td>
<td>UUID</td>
<td>Delete Player</td>
</tr>
</thead>
<tbody>
{{ range $player := .TargetUser.Players }}
<tr>
<td style="width: 30px">
<div
class="list-profile-picture"
{{ with $playerSkinURL := PlayerSkinURL $player }}
{{ if $playerSkinURL }}
style="background-image: url({{ PlayerSkinURL $player }});"
{{ end }}
{{ end }}
></div>
</td>
<td>
<a
href="{{ $.App.FrontEndURL }}/web/player/{{ $player.UUID }}"
>{{ $player.Name }}</a
>
</td>
<td>{{ $player.UUID }}</td>
<td>
<input
type="submit"
form="delete-{{ $player.UUID }}"
value="Delete"
/>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
No players yet.
{{ end }}
<p>
{{ if (eq .MaxPlayerCount 0) }}
{{ if .AdminView }}{{ .TargetUser.Username }} is{{ else }}You are{{ end }} not allowed to create players.
{{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else if (gt .MaxPlayerCount 0) }}
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have up to {{ .MaxPlayerCount }} associated player(s).
{{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else }}
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have an unlimited number of associated players.
{{ end }}
</p>
{{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }}
{{ if .App.Config.RegistrationNewPlayer.Allow }}
{{ if .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
<h4>Create a new player</h4>
{{ else }}
<p>Create a new player with a random UUID:</p>
{{ end }}
<form action="{{ .App.FrontEndURL }}/web/create-player" method="post">
<input hidden name="userUuid" value="{{ .TargetUser.UUID }}">
<input
type="text"
name="playerName"
placeholder="Player name"
maxlength="{{ .App.Constants.MaxPlayerNameLength }}"
required
/>
{{ if .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
<input
class="long"
type="text"
name="playerUuid"
placeholder="UUID (leave blank for random)"
pattern="^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"
/>
{{ end }}
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Create player" />
</form>
{{ end }}
{{ if .App.Config.RegistrationExistingPlayer.Allow }}
<h4>Import a(n) {{ .App.Config.RegistrationExistingPlayer.Nickname }} player</h4>
{{ if .App.Config.RegistrationExistingPlayer.RequireSkinVerification }}
<p>
Create a new player with the UUID of an existing
{{ .App.Config.RegistrationExistingPlayer.Nickname }} player.
Requires verification that you own the account.
</p>
<form action="{{ .App.FrontEndURL }}/web/create-player-challenge" method="get">
<input
type="text"
name="playerName"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} player name"
maxlength="{{ .App.Constants.MaxUsernameLength }}"
required
/>
<input hidden name="userUuid" value="{{ .TargetUser.UUID }}">
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Continue" />
</form>
{{ else }}
<p>
Create a new player with the UUID of an existing
{{ .App.Config.RegistrationExistingPlayer.Nickname }} player.
</p>
<form action="{{ .App.FrontEndURL }}/web/create-player" method="post">
<input
type="text"
name="playerName"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
maxlength="{{ .App.Constants.MaxPlayerNameLength }}"
required
/>
<input hidden type="checkbox" name="existingPlayer" checked />
<input hidden name="userUuid" value="{{ .TargetUser.UUID }}">
<input hidden name="returnUrl" value="{{ .URL }}" />
<input type="submit" value="Create player" />
</form>
{{ end }}
{{ end }}
{{ end }}
<h3>Account settings</h3>
<form
action="{{ .App.FrontEndURL }}/web/update-user"
method="post"
enctype="multipart/form-data"
>
<p>
<label for="password">Password</label><br />
<input
type="password"
name="password"
id="password"
class="long"
placeholder="Leave blank to keep"
/>
</p>
<p>
<label for="apiToken">API Token</label><br />
<input
type="text"
name="apiToken"
id="api-token"
class="long"
readonly
value="{{ .TargetUser.APIToken }}"
/>
<br />
<label for="reset-api-token"
>check the box to reset your API token
</label>
<input type="checkbox" name="resetApiToken" id="reset-api-token" />
</p>
<p>
<label for="preferred-language"
>Preferred Language (used by Minecraft)</label
><br />
<select
name="preferredLanguage"
id="preferred-language"
value="{{ .TargetUser.PreferredLanguage }}"
>
<option
value="sq"
{{ if eq .TargetUser.PreferredLanguage "sq" }}selected{{ end }}
>
Albanian
</option>
<option
value="ar"
{{ if eq .TargetUser.PreferredLanguage "ar" }}selected{{ end }}
>
Arabic
</option>
<option
value="be"
{{ if eq .TargetUser.PreferredLanguage "be" }}selected{{ end }}
>
Belarusian
</option>
<option
value="bg"
{{ if eq .TargetUser.PreferredLanguage "bg" }}selected{{ end }}
>
Bulgarian
</option>
<option
value="ca"
{{ if eq .TargetUser.PreferredLanguage "ca" }}selected{{ end }}
>
Catalan
</option>
<option
value="zh"
{{ if eq .TargetUser.PreferredLanguage "zh" }}selected{{ end }}
>
Chinese
</option>
<option
value="hr"
{{ if eq .TargetUser.PreferredLanguage "hr" }}selected{{ end }}
>
Croatian
</option>
<option
value="cs"
{{ if eq .TargetUser.PreferredLanguage "cs" }}selected{{ end }}
>
Czech
</option>
<option
value="da"
{{ if eq .TargetUser.PreferredLanguage "da" }}selected{{ end }}
>
Danish
</option>
<option
value="nl"
{{ if eq .TargetUser.PreferredLanguage "nl" }}selected{{ end }}
>
Dutch
</option>
<option
value="en"
{{ if eq .TargetUser.PreferredLanguage "en" }}selected{{ end }}
>
English
</option>
<option
value="et"
{{ if eq .TargetUser.PreferredLanguage "et" }}selected{{ end }}
>
Estonian
</option>
<option
value="fi"
{{ if eq .TargetUser.PreferredLanguage "fi" }}selected{{ end }}
>
Finnish
</option>
<option
value="fr"
{{ if eq .TargetUser.PreferredLanguage "fr" }}selected{{ end }}
>
French
</option>
<option
value="de"
{{ if eq .TargetUser.PreferredLanguage "de" }}selected{{ end }}
>
German
</option>
<option
value="el"
{{ if eq .TargetUser.PreferredLanguage "el" }}selected{{ end }}
>
Greek
</option>
<option
value="iw"
{{ if eq .TargetUser.PreferredLanguage "iw" }}selected{{ end }}
>
Hebrew
</option>
<option
value="hi"
{{ if eq .TargetUser.PreferredLanguage "hi" }}selected{{ end }}
>
Hindi
</option>
<option
value="hu"
{{ if eq .TargetUser.PreferredLanguage "hu" }}selected{{ end }}
>
Hungarian
</option>
<option
value="is"
{{ if eq .TargetUser.PreferredLanguage "is" }}selected{{ end }}
>
Icelandic
</option>
<option
value="in"
{{ if eq .TargetUser.PreferredLanguage "in" }}selected{{ end }}
>
Indonesian
</option>
<option
value="ga"
{{ if eq .TargetUser.PreferredLanguage "ga" }}selected{{ end }}
>
Irish
</option>
<option
value="it"
{{ if eq .TargetUser.PreferredLanguage "it" }}selected{{ end }}
>
Italian
</option>
<option
value="ja"
{{ if eq .TargetUser.PreferredLanguage "ja" }}selected{{ end }}
>
Japanese
</option>
<option
value="ko"
{{ if eq .TargetUser.PreferredLanguage "ko" }}selected{{ end }}
>
Korean
</option>
<option
value="lv"
{{ if eq .TargetUser.PreferredLanguage "lv" }}selected{{ end }}
>
Latvian
</option>
<option
value="lt"
{{ if eq .TargetUser.PreferredLanguage "lt" }}selected{{ end }}
>
Lithuanian
</option>
<option
value="mk"
{{ if eq .TargetUser.PreferredLanguage "mk" }}selected{{ end }}
>
Macedonian
</option>
<option
value="ms"
{{ if eq .TargetUser.PreferredLanguage "ms" }}selected{{ end }}
>
Malay
</option>
<option
value="mt"
{{ if eq .TargetUser.PreferredLanguage "mt" }}selected{{ end }}
>
Maltese
</option>
<option
value="no"
{{ if eq .TargetUser.PreferredLanguage "no" }}selected{{ end }}
>
Norwegian
</option>
<option
value="nb"
{{ if eq .TargetUser.PreferredLanguage "nb" }}selected{{ end }}
>
Norwegian Bokmål
</option>
<option
value="nn"
{{ if eq .TargetUser.PreferredLanguage "nn" }}selected{{ end }}
>
Norwegian Nynorsk
</option>
<option
value="pl"
{{ if eq .TargetUser.PreferredLanguage "pl" }}selected{{ end }}
>
Polish
</option>
<option
value="pt"
{{ if eq .TargetUser.PreferredLanguage "pt" }}selected{{ end }}
>
Portuguese
</option>
<option
value="ro"
{{ if eq .TargetUser.PreferredLanguage "ro" }}selected{{ end }}
>
Romanian
</option>
<option
value="ru"
{{ if eq .TargetUser.PreferredLanguage "ru" }}selected{{ end }}
>
Russian
</option>
<option
value="sr"
{{ if eq .TargetUser.PreferredLanguage "sr" }}selected{{ end }}
>
Serbian
</option>
<option
value="sk"
{{ if eq .TargetUser.PreferredLanguage "sk" }}selected{{ end }}
>
Slovak
</option>
<option
value="sl"
{{ if eq .TargetUser.PreferredLanguage "sl" }}selected{{ end }}
>
Slovenian
</option>
<option
value="es"
{{ if eq .TargetUser.PreferredLanguage "es" }}selected{{ end }}
>
Spanish
</option>
<option
value="sv"
{{ if eq .TargetUser.PreferredLanguage "sv" }}selected{{ end }}
>
Swedish
</option>
<option
value="th"
{{ if eq .TargetUser.PreferredLanguage "th" }}selected{{ end }}
>
Thai
</option>
<option
value="tr"
{{ if eq .TargetUser.PreferredLanguage "tr" }}selected{{ end }}
>
Turkish
</option>
<option
value="uk"
{{ if eq .TargetUser.PreferredLanguage "uk" }}selected{{ end }}
>
Ukrainian
</option>
<option
value="vi"
{{ if eq .TargetUser.PreferredLanguage "vi" }}selected{{ end }}
>
Vietnamese
</option>
</select>
</p>
<input hidden name="uuid" value="{{ .TargetUser.UUID }}" />
<input hidden name="returnUrl" value="{{ .URL }}" />
<p style="text-align: center;">
<input type="submit" value="Save changes" />
</p>
</form>
<p>
<details>
<summary>Delete account</summary>
<form
action="{{ .App.FrontEndURL }}/web/delete-user"
method="post"
onsubmit="return confirm('Are you sure? This action is irreversible.');"
>
<input hidden name="uuid" value="{{ .TargetUser.UUID }}" />
<input
hidden
name="returnUrl"
value="{{ if .AdminView }}
{{ .App.FrontEndURL }}/web/admin
{{ else }}
{{ .App.FrontEndURL }}
{{ end }}"
/>
<input type="submit" value="Delete account" />
</form>
</details>
</p>
{{ template "footer" . }}
{{ end }}