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

View File

@ -71,6 +71,7 @@ type Config struct {
DataDirectory string DataDirectory string
DefaultAdmins []string DefaultAdmins []string
DefaultPreferredLanguage string DefaultPreferredLanguage string
DefaultMaxPlayerCount int
Domain string Domain string
EnableBackgroundEffect bool EnableBackgroundEffect bool
EnableFooter bool EnableFooter bool
@ -117,6 +118,7 @@ func DefaultConfig() Config {
DataDirectory: DEFAULT_DATA_DIRECTORY, DataDirectory: DEFAULT_DATA_DIRECTORY,
DefaultAdmins: []string{}, DefaultAdmins: []string{},
DefaultPreferredLanguage: "en", DefaultPreferredLanguage: "en",
DefaultMaxPlayerCount: 1,
Domain: "", Domain: "",
EnableBackgroundEffect: true, EnableBackgroundEffect: true,
EnableFooter: true, EnableFooter: true,
@ -178,6 +180,9 @@ func CleanConfig(config *Config) error {
if _, err := os.Open(config.DataDirectory); err != nil { if _, err := os.Open(config.DataDirectory); err != nil {
return fmt.Errorf("Couldn't open DataDirectory: %s", err) 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.Allow {
if config.RegistrationExistingPlayer.Nickname == "" { if config.RegistrationExistingPlayer.Nickname == "" {
return errors.New("RegistrationExistingPlayer.Nickname must be set") 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 userVersion = CURRENT_USER_VERSION
} }
err := db.Transaction(func(tx *gorm.DB) error { initialUserVersion := userVersion
if userVersion < CURRENT_USER_VERSION { if initialUserVersion < CURRENT_USER_VERSION {
log.Printf("Started migration of database version %d to version %d", userVersion, 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 { if userVersion == 0 {
// Version 0 to 1 // Version 0 to 1
// Add User.OfflineUUID // Add User.OfflineUUID
@ -242,6 +244,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
APIToken: v3User.APIToken, APIToken: v3User.APIToken,
PreferredLanguage: v3User.PreferredLanguage, PreferredLanguage: v3User.PreferredLanguage,
Players: []Player{player}, Players: []Player{player},
MaxPlayerCount: Constants.MaxPlayerCountUseDefault,
} }
user.Players = append(user.Players, player) user.Players = append(user.Players, player)
users = append(users, user) 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 { if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(&users).Error; err != nil {
return err return err
} }
userVersion += 1
} }
err := tx.AutoMigrate(&User{}) err := tx.AutoMigrate(&User{})
@ -277,10 +281,13 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
return nil return nil
}) })
if err != nil { if err != nil {
return err return err
} }
if initialUserVersion < CURRENT_USER_VERSION {
log.Printf("Finished migration from version %d to %d", initialUserVersion, userVersion)
}
return nil 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"`. - `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"`. - `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: `[]`. - `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`. - `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`. - `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/). - `[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{ names := []string{
"root", "root",
"profile", "user",
"player",
"registration", "registration",
"challenge-skin", "challenge",
"admin", "admin",
} }
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"UserSkinURL": app.UserSkinURL, "PlayerSkinURL": app.PlayerSkinURL,
"InviteURL": app.InviteURL, "InviteURL": app.InviteURL,
"IsDefaultAdmin": app.IsDefaultAdmin, "IsDefaultAdmin": app.IsDefaultAdmin,
} }
@ -178,9 +179,10 @@ func getReturnURL(app *App, c *echo.Context) string {
if (*c).FormValue("returnUrl") != "" { if (*c).FormValue("returnUrl") != "" {
return (*c).FormValue("returnUrl") return (*c).FormValue("returnUrl")
} }
if (*c).QueryParam("returnUrl") != "" { // TODO no idea why this is here
return (*c).QueryParam("username") // if (*c).QueryParam("returnUrl") != "" {
} // return (*c).QueryParam("username")
// }
return app.FrontEndURL return app.FrontEndURL
} }
@ -188,7 +190,15 @@ func getReturnURL(app *App, c *echo.Context) string {
// reference to the user // reference to the user
func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Context, user *User) error) func(c echo.Context) error { 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 { 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") cookie, err := c.Cookie("browserToken")
var user User var user User
@ -239,6 +249,7 @@ func FrontRoot(app *App) func(c echo.Context) error {
App *App App *App
User *User User *User
URL string URL string
Destination string
SuccessMessage string SuccessMessage string
WarningMessage string WarningMessage string
ErrorMessage string ErrorMessage string
@ -249,6 +260,7 @@ func FrontRoot(app *App) func(c echo.Context) error {
App: app, App: app,
User: user, User: user,
URL: c.Request().URL.RequestURI(), URL: c.Request().URL.RequestURI(),
Destination: c.QueryParam("destination"),
SuccessMessage: lastSuccessMessage(&c), SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c), WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&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 { return withBrowserAdmin(app, func(c echo.Context, user *User) error {
var users []User var users []User
result := app.DB.Find(&users) result := app.DB.Preload("Players").Find(&users)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
@ -429,35 +441,42 @@ func FrontNewInvite(app *App) func(c echo.Context) error {
}) })
} }
// GET /drasl/profile // GET /drasl/user
func FrontProfile(app *App) func(c echo.Context) error { // GET /drasl/user/:uuid
type profileContext struct { func FrontUser(app *App) func(c echo.Context) error {
type userContext struct {
App *App App *App
User *User User *User
URL string URL string
SuccessMessage string SuccessMessage string
WarningMessage string WarningMessage string
ErrorMessage string ErrorMessage string
ProfileUser *User TargetUser *User
ProfileUserID string TargetUserID string
SkinURL *string SkinURL *string
CapeURL *string CapeURL *string
AdminView bool AdminView bool
MaxPlayerCount int
} }
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
var profileUser *User var targetUser *User
profileUsername := c.QueryParam("user") targetUUID := c.Param("uuid")
adminView := false adminView := false
if profileUsername == "" || profileUsername == user.Username { if targetUUID == "" || targetUUID == user.UUID {
profileUser = user var targetUserStruct User
result := app.DB.Preload("Players").First(&targetUserStruct, "uuid = ?", user.UUID)
if result.Error != nil {
return result.Error
}
targetUser = &targetUserStruct
} else { } else {
if !user.IsAdmin { if !user.IsAdmin {
return NewWebError(app.FrontEndURL, "You are not an admin.") return NewWebError(app.FrontEndURL, "You are not an admin.")
} }
var profileUserStruct User adminView = true
result := app.DB.First(&profileUserStruct, "username = ?", profileUsername) var targetUserStruct User
profileUser = &profileUserStruct result := app.DB.Preload("Players").First(&targetUserStruct, "uuid = ?", targetUUID)
if result.Error != nil { if result.Error != nil {
returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin") returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin")
if err != nil { if err != nil {
@ -465,16 +484,68 @@ func FrontProfile(app *App) func(c echo.Context) error {
} }
return NewWebError(returnURL, "User not found.") return NewWebError(returnURL, "User not found.")
} }
adminView = true targetUser = &targetUserStruct
} }
// TODO support multiple players maxPlayerCount := app.GetMaxPlayerCount(targetUser)
player := &profileUser.Players[0]
skinURL, err := app.GetSkinURL(player) 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@ -484,15 +555,15 @@ func FrontProfile(app *App) func(c echo.Context) error {
return err return err
} }
return c.Render(http.StatusOK, "profile", profileContext{ return c.Render(http.StatusOK, "player", playerContext{
App: app, App: app,
User: user, User: user,
URL: c.Request().URL.RequestURI(), URL: c.Request().URL.RequestURI(),
SuccessMessage: lastSuccessMessage(&c), SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c), WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c), ErrorMessage: lastErrorMessage(&c),
ProfileUser: profileUser, Player: &player,
ProfileUserID: id, PlayerID: id,
SkinURL: skinURL, SkinURL: skinURL,
CapeURL: capeURL, CapeURL: capeURL,
AdminView: adminView, 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 { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c) returnURL := getReturnURL(app, &c)
targetUUID := nilIfEmpty(c.FormValue("uuid")) playerUUID := c.FormValue("uuid")
playerName := nilIfEmpty(c.FormValue("playerName")) playerName := nilIfEmpty(c.FormValue("playerName"))
fallbackPlayer := nilIfEmpty(c.FormValue("fallbackPlayer")) fallbackPlayer := nilIfEmpty(c.FormValue("fallbackPlayer"))
skinModel := nilIfEmpty(c.FormValue("skinModel")) skinModel := nilIfEmpty(c.FormValue("skinModel"))
@ -569,7 +640,7 @@ func FrontUpdatePlayer(app *App) func(c echo.Context) error {
deleteCape := c.FormValue("deleteCape") == "on" deleteCape := c.FormValue("deleteCape") == "on"
var player Player var player Player
result := app.DB.First(&player, "uuid = ?", targetUUID) result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID)
if result.Error != nil { if result.Error != nil {
return NewWebError(returnURL, "Player not found.") return NewWebError(returnURL, "Player not found.")
} }
@ -646,30 +717,59 @@ func FrontLogout(app *App) func(c echo.Context) error {
}) })
} }
// GET /challenge-skin const (
func FrontChallengeSkin(app *App) func(c echo.Context) error { ChallengeActionRegister string = "register"
type challengeSkinContext struct { 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 App *App
User *User User *User
URL string URL string
SuccessMessage string SuccessMessage string
WarningMessage string WarningMessage string
ErrorMessage string ErrorMessage string
Username string PlayerName string
RegistrationProvider string RegistrationProvider string
SkinBase64 string SkinBase64 string
SkinFilename string SkinFilename string
ChallengeToken string ChallengeToken string
InviteCode string InviteCode string
Action string
UserUUID *string
} }
return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error { return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c) returnURL := getReturnURL(app, &c)
var playerName string
var userUUID *string
if action == ChallengeActionRegister {
username := c.QueryParam("username") username := c.QueryParam("username")
if err := app.ValidateUsername(username); err != nil { if err := app.ValidateUsername(username); err != nil {
return NewWebError(returnURL, "Invalid username: %s", err) 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") inviteCode := c.QueryParam("inviteCode")
@ -692,7 +792,7 @@ func FrontChallengeSkin(app *App) func(c echo.Context) error {
challengeToken = cookie.Value challengeToken = cookie.Value
} }
challengeSkinBytes, err := app.GetChallengeSkin(username, challengeToken) challengeSkinBytes, err := app.GetChallengeSkin(playerName, challengeToken)
if err != nil { if err != nil {
var userError *UserError var userError *UserError
if errors.As(err, &userError) { if errors.As(err, &userError) {
@ -702,25 +802,69 @@ func FrontChallengeSkin(app *App) func(c echo.Context) error {
} }
skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes) skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes)
return c.Render(http.StatusOK, "challenge-skin", challengeSkinContext{ return c.Render(http.StatusOK, "challenge", challengeContext{
App: app, App: app,
User: user, User: user,
URL: c.Request().URL.RequestURI(), URL: c.Request().URL.RequestURI(),
SuccessMessage: lastSuccessMessage(&c), SuccessMessage: lastSuccessMessage(&c),
WarningMessage: lastWarningMessage(&c), WarningMessage: lastWarningMessage(&c),
ErrorMessage: lastErrorMessage(&c), ErrorMessage: lastErrorMessage(&c),
Username: username, PlayerName: playerName,
SkinBase64: skinBase64, SkinBase64: skinBase64,
SkinFilename: username + "-challenge.png", SkinFilename: playerName + "-challenge.png",
ChallengeToken: challengeToken, ChallengeToken: challengeToken,
InviteCode: inviteCode, 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 // POST /register
func FrontRegister(app *App) func(c echo.Context) error { 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 { return func(c echo.Context) error {
username := c.FormValue("username") username := c.FormValue("username")
honeypot := c.FormValue("email") 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 // POST /login
func FrontLogin(app *App) func(c echo.Context) error { func FrontLogin(app *App) func(c echo.Context) error {
returnURL := app.FrontEndURL + "/web/profile"
return func(c echo.Context) error { return func(c echo.Context) error {
failureURL := getReturnURL(app, &c) failureURL := getReturnURL(app, &c)
@ -847,6 +1005,14 @@ func FrontLogin(app *App) func(c echo.Context) error {
user.BrowserToken = MakeNullString(&browserToken) user.BrowserToken = MakeNullString(&browserToken)
app.DB.Save(&user) 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) 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) 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() { switch c.Path() {
case "/", case "/",
"/web/delete-user", "/web/delete-user",
"/web/delete-player",
"/web/login", "/web/login",
"/web/logout", "/web/logout",
"/web/register", "/web/register",
@ -144,25 +145,31 @@ func (app *App) MakeServer() *echo.Echo {
t := NewTemplate(app) t := NewTemplate(app)
e.Renderer = t e.Renderer = t
e.GET("/", FrontRoot(app)) e.GET("/", FrontRoot(app))
e.GET("/web/manifest.webmanifest", FrontWebManifest(app))
e.GET("/web/admin", FrontAdmin(app)) e.GET("/web/admin", FrontAdmin(app))
e.GET("/web/challenge-skin", FrontChallengeSkin(app)) e.GET("/web/create-player-challenge", FrontCreatePlayerChallenge(app))
e.GET("/web/profile", FrontProfile(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)) 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/delete-invite", FrontDeleteInvite(app))
e.POST("/web/admin/new-invite", FrontNewInvite(app)) e.POST("/web/admin/new-invite", FrontNewInvite(app))
e.POST("/web/admin/update-users", FrontUpdateUsers(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/delete-user", FrontDeleteUser(app))
e.POST("/web/login", FrontLogin(app)) e.POST("/web/login", FrontLogin(app))
e.POST("/web/logout", FrontLogout(app)) e.POST("/web/logout", FrontLogout(app))
e.POST("/web/register", FrontRegister(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-player", FrontUpdatePlayer(app))
e.POST("/web/update-user", FrontUpdateUser(app))
e.Static("/web/public", path.Join(app.Config.DataDirectory, "public")) 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/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-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/default-skin", path.Join(app.Config.StateDirectory, "default-skin"))
e.Static("/web/texture/skin", path.Join(app.Config.StateDirectory, "skin"))
// Drasl API // Drasl API
e.GET("/drasl/api/v1/users", app.APIGetUsers()) 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) { if app.TransientLoginEligible(playerName) {
return errors.New("name is reserved for transient login") return errors.New("name is reserved for transient login")
} }
maxLength := app.Constants.MaxPlayerNameLength maxLength := Constants.MaxPlayerNameLength
if playerName == "" { if playerName == "" {
return errors.New("can't be blank") 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 { func (app *App) TransientLoginEligible(playerName string) bool {
return app.Config.TransientUsers.Allow && return app.Config.TransientUsers.Allow &&
app.TransientUsernameRegex.MatchString(playerName) && app.TransientUsernameRegex.MatchString(playerName) &&
len(playerName) <= app.Constants.MaxPlayerNameLength len(playerName) <= Constants.MaxPlayerNameLength
} }
func (app *App) ValidatePassword(password string) error { 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 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 { if !player.SkinHash.Valid {
return nil, nil return nil, nil
} }
@ -345,6 +345,16 @@ func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Cli
return &client 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 { type User struct {
IsAdmin bool IsAdmin bool
IsLocked bool IsLocked bool
@ -355,7 +365,8 @@ type User struct {
BrowserToken sql.NullString `gorm:"index"` BrowserToken sql.NullString `gorm:"index"`
APIToken string APIToken string
PreferredLanguage string PreferredLanguage string
Players []Player `gorm:"foreignKey:UserUUID"` Players []Player
MaxPlayerCount int
} }
type Player struct { type Player struct {
@ -369,7 +380,7 @@ type Player struct {
CapeHash sql.NullString `gorm:"index"` CapeHash sql.NullString `gorm:"index"`
ServerID sql.NullString ServerID sql.NullString
FallbackPlayer string FallbackPlayer string
Clients []Client `gorm:"foreignKey:PlayerUUID"` Clients []Client
User User User User
UserUUID string `gorm:"not null"` UserUUID string `gorm:"not null"`
} }

View File

@ -69,7 +69,7 @@ func (app *App) getTexture(
func (app *App) CreatePlayer( func (app *App) CreatePlayer(
caller *User, caller *User,
user *User, userUUID string,
playerName string, playerName string,
chosenUUID *string, chosenUUID *string,
existingPlayer bool, existingPlayer bool,
@ -87,6 +87,27 @@ func (app *App) CreatePlayer(
callerIsAdmin := caller.IsAdmin 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 { if err := app.ValidatePlayerName(playerName); err != nil {
return Player{}, NewBadRequestUserError("Invalid player name: %s", err) return Player{}, NewBadRequestUserError("Invalid player name: %s", err)
} }
@ -168,6 +189,7 @@ func (app *App) CreatePlayer(
player := Player{ player := Player{
UUID: playerUUID, UUID: playerUUID,
UserUUID: userUUID,
Clients: []Client{}, Clients: []Client{},
Name: playerName, Name: playerName,
OfflineUUID: offlineUUID, OfflineUUID: offlineUUID,
@ -178,11 +200,9 @@ func (app *App) CreatePlayer(
CreatedAt: time.Now(), CreatedAt: time.Now(),
NameLastChangedAt: time.Now(), NameLastChangedAt: time.Now(),
} }
user.Players = append(user.Players, player)
tx := app.DB.Begin() if err := tx.Save(&user).Error; err != nil {
defer tx.Rollback()
if err := tx.Create(&player).Error; err != nil {
if IsErrorUniqueFailedField(err, "players.name") { if IsErrorUniqueFailedField(err, "players.name") {
return Player{}, NewBadRequestUserError("That player name is taken.") return Player{}, NewBadRequestUserError("That player name is taken.")
} else if IsErrorUniqueFailedField(err, "players.uuid") { } else if IsErrorUniqueFailedField(err, "players.uuid") {
@ -194,10 +214,6 @@ func (app *App) CreatePlayer(
return Player{}, err return Player{}, err
} }
if err := app.DB.Preload("Players").First(&user, user.UUID).Error; err != nil {
return Player{}, err
}
if skinHash != nil { if skinHash != nil {
err = app.WriteSkin(*skinHash, skinBuf) err = app.WriteSkin(*skinHash, skinBuf)
if err != nil { if err != nil {
@ -241,7 +257,7 @@ func (app *App) UpdatePlayer(
} }
if playerName != nil && *playerName != player.Name { 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.") return Player{}, NewBadRequestUserError("Changing your player name is not allowed.")
} }
if err := app.ValidatePlayerName(*playerName); err != nil { 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)) result := db.Model(Client{}).Where("player_uuid = ?", player.UUID).Update("version", gorm.Expr("version + ?", 1))
return result.Error 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 { if err != nil {
return User{}, err 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) passwordSalt := make([]byte, 16)
@ -160,6 +173,7 @@ func (app *App) CreateUser(
PasswordHash: passwordHash, PasswordHash: passwordHash,
PreferredLanguage: app.Config.DefaultPreferredLanguage, PreferredLanguage: app.Config.DefaultPreferredLanguage,
APIToken: apiToken, APIToken: apiToken,
MaxPlayerCount: Constants.MaxPlayerCountUseDefault,
} }
// Player // Player
@ -313,6 +327,10 @@ func (app *App) UpdateUser(
user.APIToken = apiToken user.APIToken = apiToken
} }
if err := app.DB.Save(&user).Error; err != nil {
return User{}, err
}
return user, nil return user, nil
} }

View File

@ -82,8 +82,8 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<td colspan="2">Profile</td> <td colspan="2">User</td>
<td>Player Name</td> <td>Players</td>
<td>Admin</td> <td>Admin</td>
<td>Locked</td> <td>Locked</td>
<td>Delete Account</td> <td>Delete Account</td>
@ -95,16 +95,25 @@
<td style="width: 30px"> <td style="width: 30px">
<div <div
class="list-profile-picture" class="list-profile-picture"
style="background-image: url({{ PlayerSkinURL $user }});" {{/*style="background-image: url({{ PlayerSkinURL $user }});"*/}}
></div> ></div>
</td> </td>
<td> <td>
<a <a
href="{{ $.App.FrontEndURL }}/web/profile?user={{ $user.Username }}" href="{{ $.App.FrontEndURL }}/web/user/{{ $user.UUID }}"
>{{ $user.Username }}</a >{{ $user.Username }}</a
> >
</td> </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> <td>
<input <input
name="admin-{{ $user.Username }}" name="admin-{{ $user.Username }}"
@ -148,7 +157,7 @@
</table> </table>
<p style="text-align: center"> <p style="text-align: center">
<input hidden name="returnUrl" value="{{ $.URL }}" /> <input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="submit" value="Save Changes" /> <input type="submit" value="Save changes" />
</p> </p>
</form> </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> </h1>
</div> </div>
<div style="text-align: right"> <div style="text-align: right">
<a href="{{ .App.FrontEndURL }}/web/registration">Register</a>
{{ if .User }} {{ if .User }}
{{ if .User.IsAdmin }} {{ if .User.IsAdmin }}
<a href="{{ .App.FrontEndURL }}/web/admin">Admin</a> <a href="{{ .App.FrontEndURL }}/web/admin">Admin</a>
{{ end }} {{ end }}
<a href="{{ .App.FrontEndURL }}/web/profile" <a href="{{ .App.FrontEndURL }}/web/user"
>{{ .User.Username }}'s&nbsp;Profile</a >{{ .User.Username }}'s&nbsp;account</a
> >
<form <form
style="display: inline" style="display: inline"
action="{{ .App.FrontEndURL }}/web/logout" action="{{ .App.FrontEndURL }}/web/logout"
method="post" method="post"
> >
<input type="submit" value="Log Out" /> <input type="submit" value="Log out" />
</form> </form>
{{ else }}
<a href="{{ .App.FrontEndURL }}/web/registration">Register</a>
{{ end }} {{ end }}
</div> </div>
</nav> </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 }} {{ if .InviteCode }}
<p><em>Using invite code {{ .InviteCode }}</em></p> <p><em>Using invite code {{ .InviteCode }}</em></p>
{{ end }} {{ end }}
<form action="{{ .App.FrontEndURL }}/web/challenge-skin" method="get"> <form action="{{ .App.FrontEndURL }}/web/register-challenge" method="get">
<input <input
type="text" type="text"
name="username" name="username"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player Name" placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
maxlength="{{ .App.Constants.MaxUsernameLength }}" maxlength="{{ .App.Constants.MaxUsernameLength }}"
required required
/> />
@ -104,7 +104,7 @@
<input <input
type="text" type="text"
name="username" name="username"
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player Name" placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
maxlength="{{ .App.Constants.MaxUsernameLength }}" maxlength="{{ .App.Constants.MaxUsernameLength }}"
required required
/> />

View File

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