mirror of
https://github.com/unmojang/drasl.git
synced 2025-08-03 10:56:06 -04:00

New route on api.minecraftservices.com, see https://minecraft.wiki/w/Mojang_API#Query_player's_username
643 lines
18 KiB
Go
643 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io"
|
|
"log"
|
|
"lukechampine.com/blake3"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
func (app *App) getTexture(
|
|
textureType string,
|
|
caller *User,
|
|
textureReader *io.Reader,
|
|
textureURL *string,
|
|
) (textureHash *string, textureBuf *bytes.Buffer, err error) {
|
|
callerIsAdmin := caller != nil && caller.IsAdmin
|
|
|
|
if textureReader != nil || textureURL != nil {
|
|
allowed := false
|
|
if textureType == TextureTypeSkin {
|
|
allowed = app.Config.AllowSkins
|
|
} else if textureType == TextureTypeCape {
|
|
allowed = app.Config.AllowCapes
|
|
}
|
|
if !allowed && !callerIsAdmin {
|
|
return nil, nil, NewBadRequestUserError("Setting a %s texture is not allowed.", textureType)
|
|
}
|
|
if textureReader != nil && textureURL != nil {
|
|
return nil, nil, NewBadRequestUserError("Can't specify both a file and a URL for %s texture.", textureType)
|
|
}
|
|
if textureURL != nil {
|
|
if !app.Config.AllowTextureFromURL && !callerIsAdmin {
|
|
return nil, nil, NewBadRequestUserError("Setting a %s from a URL is not allowed.", textureType)
|
|
}
|
|
res, err := MakeHTTPClient().Get(*textureURL)
|
|
if err != nil {
|
|
return nil, nil, NewBadRequestUserError("Couldn't download a %s from that URL: %s", textureType, err)
|
|
}
|
|
defer res.Body.Close()
|
|
bodyReader := res.Body.(io.Reader)
|
|
textureReader = &bodyReader
|
|
}
|
|
validTextureHandle, err := app.GetTextureReader(textureType, *textureReader)
|
|
if err != nil {
|
|
return nil, nil, NewBadRequestUserError("Error using that %s: %s", textureType, err)
|
|
}
|
|
var hash string
|
|
textureBuf, hash, err = app.ReadTexture(validTextureHandle)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
textureHash = &hash
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (app *App) CreatePlayer(
|
|
caller *User,
|
|
userUUID string,
|
|
playerName string,
|
|
chosenUUID *string,
|
|
existingPlayer bool,
|
|
challengeToken *string,
|
|
fallbackPlayer *string,
|
|
skinModel *string,
|
|
skinReader *io.Reader,
|
|
skinURL *string,
|
|
capeReader *io.Reader,
|
|
capeURL *string,
|
|
) (Player, error) {
|
|
if caller == nil {
|
|
return Player{}, NewBadRequestUserError("Caller cannot be null.")
|
|
}
|
|
|
|
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.First(&user, "uuid = ?", userUUID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return Player{}, NewBadRequestUserError("User not found.")
|
|
}
|
|
return Player{}, err
|
|
}
|
|
|
|
if !app.Config.AllowAddingDeletingPlayers && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("You are not allowed to create new players.")
|
|
}
|
|
|
|
maxPlayerCount := app.GetMaxPlayerCount(&user)
|
|
if maxPlayerCount != Constants.MaxPlayerCountUnlimited && len(user.Players) >= maxPlayerCount && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("You are only allowed to own %d player(s).", maxPlayerCount)
|
|
}
|
|
|
|
if err := app.ValidatePlayerName(playerName); err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid player name: %s", err)
|
|
}
|
|
|
|
var playerUUID string
|
|
if existingPlayer {
|
|
// Import player
|
|
if !app.Config.RegistrationExistingPlayer.Allow && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("Importing an existing player is not allowed.")
|
|
}
|
|
|
|
if chosenUUID != nil {
|
|
return Player{}, NewBadRequestUserError("Can't import an existing player AND choose a UUID.")
|
|
}
|
|
|
|
var err error
|
|
details, err := app.ValidateChallenge(playerName, challengeToken)
|
|
if err != nil {
|
|
if app.Config.ImportExistingPlayer.RequireSkinVerification {
|
|
return Player{}, NewBadRequestUserError("Couldn't verify your skin, maybe try again: %s", err)
|
|
} else {
|
|
return Player{}, NewBadRequestUserError("Couldn't find your account, maybe try again: %s", err)
|
|
}
|
|
}
|
|
playerName = details.Username
|
|
|
|
if err := app.ValidatePlayerName(playerName); err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid player name: %s", err)
|
|
}
|
|
playerUUID = details.UUID
|
|
} else {
|
|
// New player registration
|
|
if !app.Config.RegistrationNewPlayer.Allow && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("Creating a new player is not allowed.")
|
|
}
|
|
|
|
if chosenUUID == nil {
|
|
playerUUID = uuid.New().String()
|
|
} else {
|
|
if !app.Config.RegistrationNewPlayer.AllowChoosingUUID && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("Choosing a UUID is not allowed.")
|
|
}
|
|
chosenUUIDStruct, err := uuid.Parse(*chosenUUID)
|
|
if err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid UUID: %s", err)
|
|
}
|
|
playerUUID = chosenUUIDStruct.String()
|
|
}
|
|
}
|
|
|
|
offlineUUID, err := OfflineUUID(playerName)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
|
|
if fallbackPlayer == nil {
|
|
fallbackPlayer = &playerUUID
|
|
}
|
|
if err := app.ValidatePlayerNameOrUUID(*fallbackPlayer); err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid fallback player: %s", err)
|
|
}
|
|
|
|
if skinModel == nil {
|
|
skinModel = Ptr(SkinModelClassic)
|
|
}
|
|
if !IsValidSkinModel(*skinModel) {
|
|
return Player{}, NewBadRequestUserError("Invalid skin model.")
|
|
}
|
|
|
|
skinHash, skinBuf, err := app.getTexture("skin", caller, skinReader, skinURL)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
|
|
capeHash, capeBuf, err := app.getTexture("cape", caller, capeReader, capeURL)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
|
|
player := Player{
|
|
UUID: playerUUID,
|
|
UserUUID: userUUID,
|
|
Clients: []Client{},
|
|
Name: playerName,
|
|
OfflineUUID: offlineUUID,
|
|
FallbackPlayer: *fallbackPlayer,
|
|
SkinModel: *skinModel,
|
|
SkinHash: MakeNullString(skinHash),
|
|
CapeHash: MakeNullString(capeHash),
|
|
CreatedAt: time.Now(),
|
|
NameLastChangedAt: time.Now(),
|
|
}
|
|
if err := tx.Create(&player).Error; err != nil {
|
|
if IsErrorUniqueFailedField(err, "players.name") {
|
|
return Player{}, NewBadRequestUserError("That player name is taken.")
|
|
} else if IsErrorUniqueFailedField(err, "players.uuid") {
|
|
return Player{}, NewBadRequestUserError("That UUID is taken.")
|
|
} else if IsErrorPlayerNameTakenByUsername(err) {
|
|
return Player{}, NewBadRequestUserError("That player name is in use as another user's username.")
|
|
} else {
|
|
return Player{}, err
|
|
}
|
|
}
|
|
|
|
user.Players = append(user.Players, player)
|
|
if err := tx.Save(&user).Error; err != nil {
|
|
return Player{}, err
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
return Player{}, err
|
|
}
|
|
|
|
if skinHash != nil {
|
|
err = app.WriteSkin(*skinHash, skinBuf)
|
|
if err != nil {
|
|
return player, NewBadRequestUserError("Error saving the skin.")
|
|
}
|
|
}
|
|
|
|
if capeHash != nil {
|
|
err = app.WriteCape(*capeHash, capeBuf)
|
|
if err != nil {
|
|
return player, NewBadRequestUserError("Error saving the cape.")
|
|
}
|
|
}
|
|
|
|
return player, nil
|
|
}
|
|
|
|
func (app *App) UpdatePlayer(
|
|
caller *User,
|
|
player Player,
|
|
playerName *string,
|
|
fallbackPlayer *string,
|
|
skinModel *string,
|
|
skinReader *io.Reader,
|
|
skinURL *string,
|
|
deleteSkin bool,
|
|
capeReader *io.Reader,
|
|
capeURL *string,
|
|
deleteCape bool,
|
|
) (Player, error) {
|
|
if caller == nil {
|
|
return Player{}, NewBadRequestUserError("Caller cannot be null.")
|
|
}
|
|
|
|
callerIsAdmin := caller.IsAdmin
|
|
|
|
if player.UserUUID != caller.UUID && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("Can't update a player belonging to another user unless you're an admin.")
|
|
}
|
|
|
|
if playerName != nil && *playerName != player.Name {
|
|
if !app.Config.AllowChangingPlayerName && !callerIsAdmin {
|
|
return Player{}, NewBadRequestUserError("Changing your player name is not allowed.")
|
|
}
|
|
if err := app.ValidatePlayerName(*playerName); err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid player name: %s", err)
|
|
}
|
|
offlineUUID, err := OfflineUUID(*playerName)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
player.Name = *playerName
|
|
player.OfflineUUID = offlineUUID
|
|
player.NameLastChangedAt = time.Now()
|
|
}
|
|
|
|
if fallbackPlayer != nil && *fallbackPlayer != player.FallbackPlayer {
|
|
if err := app.ValidatePlayerNameOrUUID(*fallbackPlayer); err != nil {
|
|
return Player{}, NewBadRequestUserError("Invalid fallback player: %s", err)
|
|
}
|
|
player.FallbackPlayer = *fallbackPlayer
|
|
}
|
|
|
|
if skinModel != nil {
|
|
if !IsValidSkinModel(*skinModel) {
|
|
return Player{}, NewBadRequestUserError("Invalid skin model.")
|
|
}
|
|
player.SkinModel = *skinModel
|
|
}
|
|
|
|
// Skin and cape updates are done as follows:
|
|
// 1. Validate with ValidateSkin/ValidateCape
|
|
// 2. Read the texture into memory and hash it with ReadTexture
|
|
// 3. Update the database
|
|
// 4. If the database updated successfully:
|
|
// - Acquire a lock to the texture file
|
|
// - If the texture file doesn't exist, write it to disk
|
|
// - Delete the old texture if it's unused
|
|
//
|
|
// Any update should happen first to the DB, then to the filesystem. We
|
|
// don't attempt to roll back changes to the DB if we fail to write to
|
|
// the filesystem.
|
|
|
|
skinHash, skinBuf, err := app.getTexture("skin", caller, skinReader, skinURL)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
oldSkinHash := UnmakeNullString(&player.SkinHash)
|
|
if skinHash != nil {
|
|
player.SkinHash = MakeNullString(skinHash)
|
|
} else if deleteSkin {
|
|
player.SkinHash = MakeNullString(nil)
|
|
}
|
|
|
|
capeHash, capeBuf, err := app.getTexture("cape", caller, capeReader, capeURL)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
oldCapeHash := UnmakeNullString(&player.CapeHash)
|
|
if capeHash != nil {
|
|
player.CapeHash = MakeNullString(capeHash)
|
|
} else if deleteCape {
|
|
player.CapeHash = MakeNullString(nil)
|
|
}
|
|
|
|
newSkinHash := UnmakeNullString(&player.SkinHash)
|
|
newCapeHash := UnmakeNullString(&player.CapeHash)
|
|
|
|
err = app.DB.Save(&player).Error
|
|
if err != nil {
|
|
if IsErrorUniqueFailedField(err, "players.name") {
|
|
return Player{}, NewBadRequestUserError("That player name is taken.")
|
|
} else if IsErrorPlayerNameTakenByUsername(err) {
|
|
return Player{}, NewBadRequestUserError("That player name is in use as another user's username.")
|
|
}
|
|
return Player{}, err
|
|
}
|
|
|
|
if !PtrEquals(oldSkinHash, newSkinHash) {
|
|
if newSkinHash != nil {
|
|
err = app.WriteSkin(*newSkinHash, skinBuf)
|
|
if err != nil {
|
|
return Player{}, NewBadRequestUserError("Error saving the skin.")
|
|
}
|
|
}
|
|
|
|
err = app.DeleteSkinIfUnused(oldSkinHash)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
}
|
|
if !PtrEquals(oldCapeHash, newCapeHash) {
|
|
if newCapeHash != nil {
|
|
err = app.WriteCape(*newCapeHash, capeBuf)
|
|
if err != nil {
|
|
return Player{}, NewBadRequestUserError("Error saving the cape.")
|
|
}
|
|
}
|
|
|
|
err = app.DeleteCapeIfUnused(oldCapeHash)
|
|
if err != nil {
|
|
return Player{}, err
|
|
}
|
|
}
|
|
|
|
return player, nil
|
|
}
|
|
|
|
type ProxiedAccountDetails struct {
|
|
Username string
|
|
UUID string
|
|
}
|
|
|
|
func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*ProxiedAccountDetails, error) {
|
|
base, err := url.Parse(app.Config.ImportExistingPlayer.AccountURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base.Path, err = url.JoinPath(base.Path, "users/profiles/minecraft/"+playerName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := MakeHTTPClient().Get(base.String())
|
|
if err != nil {
|
|
log.Printf("Couldn't access registration server at %s: %s\n", base.String(), err)
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
log.Printf("Request to registration server at %s resulted in status code %d\n", base.String(), res.StatusCode)
|
|
return nil, errors.New("registration server returned error")
|
|
}
|
|
|
|
var idRes PlayerNameToIDResponse
|
|
err = json.NewDecoder(res.Body).Decode(&idRes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
base, err = url.Parse(app.Config.ImportExistingPlayer.SessionURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Invalid SessionURL %s: %s", app.Config.ImportExistingPlayer.SessionURL, err)
|
|
}
|
|
base.Path, err = url.JoinPath(base.Path, "session/minecraft/profile/"+idRes.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err = MakeHTTPClient().Get(base.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
log.Printf("Request to registration server at %s resulted in status code %d\n", base.String(), res.StatusCode)
|
|
return nil, errors.New("registration server returned error")
|
|
}
|
|
|
|
var profileRes SessionProfileResponse
|
|
err = json.NewDecoder(res.Body).Decode(&profileRes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id := profileRes.ID
|
|
accountUUID, err := IDToUUID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
details := ProxiedAccountDetails{
|
|
Username: profileRes.Name,
|
|
UUID: accountUUID,
|
|
}
|
|
if !app.Config.ImportExistingPlayer.RequireSkinVerification {
|
|
return &details, nil
|
|
}
|
|
|
|
for _, property := range profileRes.Properties {
|
|
if property.Name == "textures" {
|
|
textureJSON, err := base64.StdEncoding.DecodeString(property.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var texture texturesValue
|
|
err = json.Unmarshal(textureJSON, &texture)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if texture.Textures.Skin == nil {
|
|
return nil, errors.New("player does not have a skin")
|
|
}
|
|
res, err = MakeHTTPClient().Get(texture.Textures.Skin.URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
rgba_img, err := png.Decode(res.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
img, ok := rgba_img.(*image.NRGBA)
|
|
if !ok {
|
|
return nil, errors.New("invalid image")
|
|
}
|
|
|
|
challenge := make([]byte, 64)
|
|
challengeByte := 0
|
|
for y := SKIN_WINDOW_Y_MIN; y < SKIN_WINDOW_Y_MAX; y += 1 {
|
|
for x := SKIN_WINDOW_X_MIN; x < SKIN_WINDOW_X_MAX; x += 1 {
|
|
c := img.NRGBAAt(x, y)
|
|
challenge[challengeByte] = c.R
|
|
challenge[challengeByte+1] = c.G
|
|
challenge[challengeByte+2] = c.B
|
|
challenge[challengeByte+3] = c.A
|
|
|
|
challengeByte += 4
|
|
}
|
|
}
|
|
|
|
if challengeToken == nil {
|
|
return nil, errors.New("missing challenge token")
|
|
}
|
|
correctChallenge := app.GetChallenge(playerName, *challengeToken)
|
|
|
|
if !bytes.Equal(challenge, correctChallenge) {
|
|
return nil, errors.New("skin does not match")
|
|
}
|
|
|
|
return &details, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("registration server didn't return textures")
|
|
}
|
|
|
|
func MakeChallengeToken() (string, error) {
|
|
return RandomBase62(16)
|
|
}
|
|
|
|
func (app *App) GetChallenge(playerName string, token string) []byte {
|
|
// This challenge is nice because:
|
|
// - it doesn't depend on any serverside state
|
|
// - an attacker can't use it to verify a different player name, since the
|
|
// hash incorporates the player name
|
|
// - an attacker can't generate their own challenges, since the hash
|
|
// includes a hash of the instance's private key
|
|
// - an attacker can't steal the skin mid-verification and register the
|
|
// account themselves, since the hash incorporates a token known only to
|
|
// the verifying browser
|
|
challengeBytes := bytes.Join([][]byte{
|
|
[]byte(playerName),
|
|
app.PrivateKeyB3Sum512[:],
|
|
[]byte(token),
|
|
}, []byte{byte(0)})
|
|
|
|
sum := blake3.Sum512(challengeBytes)
|
|
return sum[:]
|
|
}
|
|
|
|
func (app *App) GetChallengeSkin(playerName string, challengeToken string) ([]byte, error) {
|
|
if err := app.ValidatePlayerName(playerName); err != nil {
|
|
return nil, NewBadRequestUserError("Invalid player name: %s", err)
|
|
}
|
|
|
|
// challenge is a 512-bit, 64 byte checksum
|
|
challenge := app.GetChallenge(playerName, challengeToken)
|
|
|
|
// Embed the challenge into a skin
|
|
skinSize := 64
|
|
img := image.NewNRGBA(image.Rectangle{image.Point{0, 0}, image.Point{skinSize, skinSize}})
|
|
|
|
challengeByte := 0
|
|
for y := 0; y < skinSize; y += 1 {
|
|
for x := 0; x < skinSize; x += 1 {
|
|
var col color.NRGBA
|
|
if SKIN_WINDOW_Y_MIN <= y && y < SKIN_WINDOW_Y_MAX && SKIN_WINDOW_X_MIN <= x && x < SKIN_WINDOW_X_MAX {
|
|
col = color.NRGBA{
|
|
challenge[challengeByte],
|
|
challenge[challengeByte+1],
|
|
challenge[challengeByte+2],
|
|
challenge[challengeByte+3],
|
|
}
|
|
challengeByte += 4
|
|
} else {
|
|
col = app.VerificationSkinTemplate.At(x, y).(color.NRGBA)
|
|
}
|
|
img.SetNRGBA(x, y, col)
|
|
}
|
|
}
|
|
|
|
var imgBuffer bytes.Buffer
|
|
err := png.Encode(&imgBuffer, img)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return imgBuffer.Bytes(), nil
|
|
}
|
|
|
|
func (app *App) InvalidatePlayer(db *gorm.DB, player *Player) error {
|
|
if player == nil {
|
|
return nil
|
|
}
|
|
result := db.Model(Client{}).Where("player_uuid = ?", player.UUID).Update("version", gorm.Expr("version + ?", 1))
|
|
return result.Error
|
|
}
|
|
|
|
func (app *App) InvalidateUser(db *gorm.DB, user *User) error {
|
|
result := db.Model(Client{}).Where("user_uuid = ?", user.UUID).Update("version", gorm.Expr("version + ?", 1))
|
|
return result.Error
|
|
}
|
|
|
|
func (app *App) DeletePlayer(caller *User, player *Player) error {
|
|
if !app.Config.AllowAddingDeletingPlayers && !caller.IsAdmin {
|
|
return NewUserError(http.StatusForbidden, "You are not allowed to delete players.")
|
|
}
|
|
|
|
if caller.UUID != player.UserUUID && !caller.IsAdmin {
|
|
return NewUserError(http.StatusForbidden, "You don't own that player.")
|
|
}
|
|
|
|
if err := app.DB.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
|
|
}
|
|
|
|
func (app *App) PlayerSkinURL(player *Player) (*string, error) {
|
|
if !player.SkinHash.Valid {
|
|
return nil, nil
|
|
}
|
|
url, err := app.SkinURL(player.SkinHash.String)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &url, nil
|
|
}
|
|
|
|
func (app *App) FindPlayerByUUIDOrOfflineUUID(uuid_ string) (*Player, *User, error) {
|
|
var player Player
|
|
result := app.DB.Preload("User").First(&player, "uuid = ?", uuid_)
|
|
if result.Error == nil {
|
|
return &player, &player.User, nil
|
|
}
|
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
return nil, nil, result.Error
|
|
}
|
|
|
|
if app.Config.OfflineSkins {
|
|
result = app.DB.Preload("User").First(&player, "offline_uuid = ?", uuid_)
|
|
if result.Error == nil {
|
|
return &player, &player.User, nil
|
|
}
|
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
return nil, nil, result.Error
|
|
}
|
|
}
|
|
|
|
return nil, nil, nil
|
|
}
|