diff --git a/account.go b/account.go index f8f661d..a1ea2ed 100644 --- a/account.go +++ b/account.go @@ -211,11 +211,10 @@ func AccountPlayerNameToID(app *App) func(c echo.Context) error { // This error message is consistent with POST // https://api.mojang.com/users/profiles/minecraft/:playerName as // of 2025-04-03 - errorMessage := fmt.Sprintf("getProfileName.name: Invalid profile name") return &YggdrasilError{ Code: http.StatusBadRequest, Error_: mo.Some("CONSTRAINT_VIOLATION"), - ErrorMessage: mo.Some(errorMessage), + ErrorMessage: mo.Some("getProfileName.name: Invalid profile name"), } } @@ -261,11 +260,10 @@ func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error { if len(playerNames) == 0 { // This error message is consistent with POST // https://api.mojang.com/profiles/minecraft as of 2025-04-02 - errorMessage := fmt.Sprintf("getProfileName.profileNames: must not be empty") return &YggdrasilError{ Code: http.StatusBadRequest, Error_: mo.Some("CONSTRAINT_VIOLATION"), - ErrorMessage: mo.Some(errorMessage), + ErrorMessage: mo.Some("getProfileName.profileNames: must not be empty"), } } if len(playerNames) > MAX_PLAYER_NAMES_TO_IDS { diff --git a/main.go b/main.go index 2918c48..ca113f9 100644 --- a/main.go +++ b/main.go @@ -353,6 +353,7 @@ func (app *App) MakeServer() *echo.Echo { servicesUploadSkin := ServicesUploadSkin(app) servicesChangeName := ServicesChangeName(app) servicesPublicKeys := ServicesPublicKeys(app) + servicesIDToPlayerName := app.ServicesIDToPlayerName() e.GET("/privileges", servicesPlayerAttributes) e.GET("/player/attributes", servicesPlayerAttributes) @@ -367,6 +368,7 @@ func (app *App) MakeServer() *echo.Echo { e.POST("/minecraft/profile/skins", servicesUploadSkin) e.PUT("/minecraft/profile/name/:playerName", servicesChangeName) e.GET("/publickeys", servicesPublicKeys) + e.GET("/minecraft/profile/lookup/:id", servicesIDToPlayerName) e.GET("/minecraft/profile/lookup/name/:playerName", accountPlayerNameToID) e.POST("/minecraft/profile/lookup/bulk/byname", accountPlayerNamesToIDs) @@ -383,6 +385,7 @@ func (app *App) MakeServer() *echo.Echo { e.POST("/services/minecraft/profile/skins", servicesUploadSkin) e.PUT("/services/minecraft/profile/name/:playerName", servicesChangeName) e.GET("/services/publickeys", servicesPublicKeys) + e.GET("/services/minecraft/profile/lookup/:id", servicesIDToPlayerName) e.GET("/services/minecraft/profile/lookup/name/:playerName", accountPlayerNameToID) e.POST("/services/minecraft/profile/lookup/bulk/byname", accountPlayerNamesToIDs) @@ -399,6 +402,7 @@ func (app *App) MakeServer() *echo.Echo { e.POST("/authlib-injector/minecraftservices/minecraft/profile/skins", servicesUploadSkin) e.PUT("/authlib-injector/minecraftservices/minecraft/profile/name/:playerName", servicesChangeName) e.GET("/authlib-injector/minecraftservices/publickeys", servicesPublicKeys) + e.GET("/authlib-injector/minecraftservices/minecraft/profile/lookup/:id", servicesIDToPlayerName) e.GET("/authlib-injector/minecraftservices/minecraft/profile/lookup/name/:playerName", accountPlayerNameToID) e.POST("/authlib-injector/minecraftservices/minecraft/profile/lookup/bulk/byname", accountPlayerNamesToIDs) diff --git a/model.go b/model.go index 6f2d02a..f20d136 100644 --- a/model.go +++ b/model.go @@ -86,6 +86,26 @@ func IDToUUID(id string) (string, error) { return id[0:8] + "-" + id[8:12] + "-" + id[12:16] + "-" + id[16:20] + "-" + id[20:], nil } +func ParseUUID(idOrUUID string) (string, error) { + if len(idOrUUID) == 32 { + uuid_, err := IDToUUID(idOrUUID) + if err != nil { + return "", err + } + if _, err := uuid.Parse(uuid_); err != nil { + return "", err + } + return uuid_, nil + } + if len(idOrUUID) == 36 { + if _, err := uuid.Parse(idOrUUID); err != nil { + return "", err + } + return idOrUUID, nil + } + return "", errors.New("invalid ID or UUID") +} + func (app *App) ValidatePlayerName(playerName string) error { if app.TransientLoginEligible(playerName) { return errors.New("name is reserved for transient login") diff --git a/player.go b/player.go index 21d9dd9..75ee992 100644 --- a/player.go +++ b/player.go @@ -497,10 +497,6 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P return nil, errors.New("skin does not match") } - if err != nil { - return nil, err - } - return &details, nil } } @@ -621,3 +617,26 @@ func (app *App) PlayerSkinURL(player *Player) (*string, error) { } 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 +} diff --git a/services.go b/services.go index 281f387..84851a3 100644 --- a/services.go +++ b/services.go @@ -13,8 +13,10 @@ import ( "github.com/labstack/echo/v4" "github.com/samber/mo" "gorm.io/gorm" + "log" "math/big" "net/http" + "net/url" "regexp" "strings" "time" @@ -588,3 +590,69 @@ func ServicesPublicKeys(app *App) func(c echo.Context) error { return c.JSONBlob(http.StatusOK, responseBlob) } } + +// GET /minecraft/profile/lookup/:id +func (app *App) ServicesIDToPlayerName() func(c echo.Context) error { + return func(c echo.Context) error { + idParam := c.Param("id") + uuid_, err := ParseUUID(idParam) + if err != nil { + return &YggdrasilError{ + Code: http.StatusBadRequest, + ErrorMessage: mo.Some(fmt.Sprintf("Not a valid UUID: %s", idParam)), + } + } + + playerName := mo.None[*string]() + + player, _, err := app.FindPlayerByUUIDOrOfflineUUID(uuid_) + if err != nil { + return err + } + if player != nil { + playerName = mo.Some(&player.Name) + } else { + for _, fallbackAPIServer := range app.FallbackAPIServers { + reqURL := fallbackAPIServer.Config.SessionURL + "/session/minecraft/profile/" + url.PathEscape(uuid_) + res, err := app.CachedGet(reqURL+"?unsigned=true", fallbackAPIServer.Config.CacheTTLSeconds) + if err != nil { + log.Printf("Couldn't access fallback API server at %s: %s\n", reqURL, err) + continue + } + + if res.StatusCode != http.StatusOK { + continue + } + + var profileRes SessionProfileResponse + err = json.Unmarshal(res.BodyBytes, &profileRes) + if err != nil { + log.Printf("Received invalid response from fallback API server at %s\n", reqURL) + } + + playerName = mo.Some(&profileRes.Name) + break + } + } + + if n, ok := playerName.Get(); ok { + id, err := UUIDToID(uuid_) + if err != nil { + return err + } + return c.JSON(http.StatusOK, PlayerNameToIDResponse{ + Name: *n, + ID: id, + }) + } + + // Consistent with + // https://api.minecraftservices.com/minecraft/profile/lookup/:uuid as + // of 2025-04-04 + return &YggdrasilError{ + Code: http.StatusNotFound, + Error_: mo.Some("NOT FOUND"), + ErrorMessage: mo.Some("Not Found"), + } + } +} diff --git a/services_test.go b/services_test.go index 613593b..aa8fef7 100644 --- a/services_test.go +++ b/services_test.go @@ -41,8 +41,9 @@ func TestServices(t *testing.T) { ts.CreateTestUser(t, ts.App, ts.Server, TEST_USERNAME) ts.CreateTestUser(t, ts.App, ts.Server, SERVICES_EXISTING_USERNAME) ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_USERNAME) + ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_OTHER_USERNAME) - // Set the red skin on the aux user + // Set the red skin on the aux TEST_USERNAME user var user User assert.Nil(t, ts.AuxApp.DB.First(&user, "username = ?", TEST_USERNAME).Error) player := user.Players[0] @@ -61,6 +62,7 @@ func TestServices(t *testing.T) { t.Run("Test GET /rollout/v1/msamigration", ts.testServicesMSAMigration) t.Run("Test POST /publickey", ts.testServicesPublicKeys) t.Run("Test POST /minecraft/profile/lookup/bulk/byname", ts.makeTestAccountPlayerNamesToIDs("/minecraft/profile/lookup/bulk/byname")) + t.Run("Test GET /minecraft/profile/lookup/:id", ts.testServicesIDToPlayerName) } { ts := &TestSuite{} @@ -491,6 +493,35 @@ func (ts *TestSuite) testServicesPublicKeys(t *testing.T) { } } +func (ts *TestSuite) testServicesIDToPlayerName(t *testing.T) { + { + // Non-fallback + var player Player + assert.Nil(t, ts.App.DB.First(&player, "name = ?", TEST_PLAYER_NAME).Error) + + rec := ts.Get(t, ts.Server, "/minecraft/profile/lookup/"+player.UUID, nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) + var response PlayerNameToIDResponse + assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) + + assert.Equal(t, response.Name, player.Name) + assert.Equal(t, response.ID, Unwrap(UUIDToID(player.UUID))) + } + { + // Fallback + var player Player + assert.Nil(t, ts.AuxApp.DB.First(&player, "name = ?", TEST_OTHER_PLAYER_NAME).Error) + + rec := ts.Get(t, ts.Server, "/minecraft/profile/lookup/"+player.UUID, nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) + var response PlayerNameToIDResponse + assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) + + assert.Equal(t, response.Name, player.Name) + assert.Equal(t, response.ID, Unwrap(UUIDToID(player.UUID))) + } +} + func (ts *TestSuite) makeTestAccountPlayerNamesToIDs(url string) func(t *testing.T) { return func(t *testing.T) { payload := []string{TEST_USERNAME, "nonexistent"} diff --git a/session.go b/session.go index e7e6b18..acb4b8b 100644 --- a/session.go +++ b/session.go @@ -3,7 +3,6 @@ package main import ( "errors" "fmt" - "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/samber/mo" "gorm.io/gorm" @@ -220,57 +219,23 @@ func SessionCheckServer(app *App) func(c echo.Context) error { func SessionProfile(app *App, fromAuthlibInjector bool) func(c echo.Context) error { return func(c echo.Context) error { id := c.Param("id") - - var uuid_ string - uuid_, err := IDToUUID(id) + uuid_, err := ParseUUID(id) if err != nil { - _, err = uuid.Parse(id) - if err != nil { - return &YggdrasilError{ - Code: http.StatusBadRequest, - ErrorMessage: mo.Some(fmt.Sprintf("Not a valid UUID: %s", c.Param("id"))), - } + return &YggdrasilError{ + Code: http.StatusBadRequest, + ErrorMessage: mo.Some(fmt.Sprintf("Not a valid UUID: %s", id)), } - uuid_ = id } - findPlayer := func() (*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, err - } - - // Could be an offline UUID - 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, err - } - } - - return nil, nil, nil - } - - player, user, err := findPlayer() + player, user, err := app.FindPlayerByUUIDOrOfflineUUID(uuid_) if err != nil { return err } if player == nil { - for _, fallbackAPIServer := range app.Config.FallbackAPIServers { - reqURL, err := url.JoinPath(fallbackAPIServer.SessionURL, "session/minecraft/profile", id) - if err != nil { - log.Println(err) - continue - } - res, err := app.CachedGet(reqURL+"?unsigned=false", fallbackAPIServer.CacheTTLSeconds) + for _, fallbackAPIServer := range app.FallbackAPIServers { + reqURL := fallbackAPIServer.Config.SessionURL + "/session/minecraft/profile/" + url.PathEscape(uuid_) + res, err := app.CachedGet(reqURL+"?unsigned=false", fallbackAPIServer.Config.CacheTTLSeconds) if err != nil { log.Printf("Couldn't access fallback API server at %s: %s\n", reqURL, err) continue