Implement GET /minecraft/profile/lookup/:id

New route on api.minecraftservices.com, see
https://minecraft.wiki/w/Mojang_API#Query_player's_username
This commit is contained in:
Evan Goode 2025-04-04 19:30:30 -04:00
parent bf62ef54eb
commit 30ba03adf4
7 changed files with 157 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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"),
}
}
}

View File

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

View File

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