drasl/account.go
2025-04-04 21:00:08 -04:00

356 lines
12 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
mapset "github.com/deckarep/golang-set/v2"
"github.com/labstack/echo/v4"
"github.com/samber/mo"
"gorm.io/gorm"
"log"
"net/http"
"strings"
"time"
)
type PlayerNameToIDResponse struct {
Name string `json:"name"`
ID string `json:"id"`
}
type playerNameToIDJob struct {
LowerName string
ReturnCh chan mo.Option[PlayerNameToIDResponse]
}
func (fallbackAPIServer *FallbackAPIServer) PlayerNamesToIDs(remainingLowerNames mapset.Set[string]) []PlayerNameToIDResponse {
responses := make([]PlayerNameToIDResponse, 0, remainingLowerNames.Cardinality())
// Use responses from the cache, if available.
if fallbackAPIServer.PlayerNameToIDCache != nil {
for _, lowerName := range remainingLowerNames.ToSlice() {
cachedResponse, found := fallbackAPIServer.PlayerNameToIDCache.Get(lowerName)
if found {
remainingLowerNames.Remove(lowerName)
if response, isPresent := cachedResponse.(mo.Option[PlayerNameToIDResponse]).Get(); isPresent {
responses = append(responses, response)
}
}
}
}
playerNameToIDJobs := make([]playerNameToIDJob, 0, remainingLowerNames.Cardinality())
for lowerName := range mapset.Elements(remainingLowerNames) {
playerNameToIDJobs = append(playerNameToIDJobs, playerNameToIDJob{
LowerName: lowerName,
ReturnCh: make(chan mo.Option[PlayerNameToIDResponse], 1),
})
}
fallbackAPIServer.PlayerNameToIDJobCh <- playerNameToIDJobs
for _, job := range playerNameToIDJobs {
maybeRes := <-job.ReturnCh
if res, ok := maybeRes.Get(); ok {
responses = append(responses, res)
}
}
return responses
}
func (app *App) PlayerNamesToIDsWorker(fallbackAPIServer *FallbackAPIServer) {
// All communication with the POST /profiles/minecraft (a.k.a. POST
// /minecraft/profile/lookup/bulk/byname) route on a fallback API server is
// done by a single goroutine running this function. It buffers a queue of
// requested (lowercase) player names and makes requests to the fallback
// API server in batches of MAX_PLAYER_NAMES_TO_IDS, waiting at least
// MAX_PLAYER_NAMES_TO_IDS_INTERVAL in between requests, in order to avoid
// rate-limiting.
url := fallbackAPIServer.Config.AccountURL + "/profiles/minecraft"
// Queue of player names to fetch that may exceed MAX_PLAYER_NAMES_TO_IDS
// in size
lowerNameQueue := make([]*string, 0)
// Map lowercase player name to a list of return channels where we should
// send the result of the query for that lowercase player name
lowerNameToResponseChs := make(map[string][]chan mo.Option[PlayerNameToIDResponse])
var timeout <-chan time.Time = nil
for {
select {
case jobs := <-fallbackAPIServer.PlayerNameToIDJobCh:
for _, job := range PtrSlice(jobs) {
// Double-check the cache
if fallbackAPIServer.PlayerNameToIDCache != nil {
cachedResponse, found := fallbackAPIServer.PlayerNameToIDCache.Get(job.LowerName)
if found {
job.ReturnCh <- cachedResponse.(mo.Option[PlayerNameToIDResponse])
continue
}
}
// Double-check for validity, invalid player names will spoil
// the entire batch. We will assume that if a player name is
// valid to Drasl, it is valid on all fallback API servers (if
// this becomes a problem in the future, we may need a
// FallbackAPIServer.ValidPlayerNameRegex.
if app.ValidatePlayerName(job.LowerName) != nil {
job.ReturnCh <- mo.None[PlayerNameToIDResponse]()
continue
}
if _, ok := lowerNameToResponseChs[job.LowerName]; !ok {
lowerNameQueue = append(lowerNameQueue, &job.LowerName)
}
lowerNameToResponseChs[job.LowerName] = append(lowerNameToResponseChs[job.LowerName], job.ReturnCh)
}
case <-timeout:
timeout = nil
}
// Wait until we have player names in the queue AND have waited long
// enough to make another request
if !(len(lowerNameQueue) > 0 && timeout == nil) {
continue
}
// Dequeue the next batch of MAX_PLAYER_NAMES_TO_IDS lowercase player names
batchSize := min(len(lowerNameQueue), MAX_PLAYER_NAMES_TO_IDS)
batch := lowerNameQueue[:batchSize]
lowerNameQueue = lowerNameQueue[batchSize:]
fallbackResponses, fallbackError := (func() ([]PlayerNameToIDResponse, error) {
body, err := json.Marshal(batch)
if err != nil {
return nil, err
}
res, err := MakeHTTPClient().Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received status code %d", res.StatusCode)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(res.Body)
if err != nil {
return nil, err
}
var fallbackResponses []PlayerNameToIDResponse
err = json.Unmarshal(buf.Bytes(), &fallbackResponses)
if err != nil {
return nil, err
}
return fallbackResponses, nil
})()
timeout = time.After(MAX_PLAYER_NAMES_TO_IDS_INTERVAL)
lowerNameToResponse := make(map[string]*PlayerNameToIDResponse)
if fallbackError != nil {
log.Printf("Error requesting player IDs from fallback API server at %s: %s", url, fallbackError)
} else {
for _, fallbackResponse := range PtrSlice(fallbackResponses) {
lowerName := strings.ToLower(fallbackResponse.Name)
lowerNameToResponse[lowerName] = fallbackResponse
}
}
for _, lowerName := range batch {
if fallbackError == nil && fallbackAPIServer.PlayerNameToIDCache != nil {
ttl := time.Duration(fallbackAPIServer.Config.CacheTTLSeconds) * time.Second
if res, ok := lowerNameToResponse[*lowerName]; ok {
fallbackAPIServer.PlayerNameToIDCache.SetWithTTL(*lowerName, mo.Some(*res), 0, ttl)
} else {
fallbackAPIServer.PlayerNameToIDCache.SetWithTTL(*lowerName, mo.None[PlayerNameToIDResponse](), 0, ttl)
}
fallbackAPIServer.PlayerNameToIDCache.Wait()
}
for _, responseCh := range lowerNameToResponseChs[*lowerName] {
if res, ok := lowerNameToResponse[*lowerName]; ok {
responseCh <- mo.Some(*res)
} else {
responseCh <- mo.None[PlayerNameToIDResponse]()
}
}
}
clear(lowerNameToResponseChs)
}
}
// GET /users/profiles/minecraft/:playerName
// GET /minecraft/profile/lookup/name/:playerName
// https://minecraft.wiki/w/Mojang_API#Query_player's_UUID
func AccountPlayerNameToID(app *App) func(c echo.Context) error {
return func(c echo.Context) error {
playerName := c.Param("playerName")
if len(playerName) > Constants.MaxPlayerNameLength {
// This error message is consistent with GET
// https://api.mojang.com/users/profiles/minecraft/:playerName as
// of 2025-04-02
errorMessage := fmt.Sprintf("getProfileName.name: Invalid profile name, getProfileName.name: size must be between 1 and %d", Constants.MaxPlayerNameLength)
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some(errorMessage),
}
}
lowerName := strings.ToLower(playerName)
if app.ValidatePlayerName(lowerName) != nil {
// This error message is consistent with POST
// https://api.mojang.com/users/profiles/minecraft/:playerName as
// of 2025-04-03
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some("getProfileName.name: Invalid profile name"),
}
}
var player Player
result := app.DB.First(&player, "name = ?", lowerName)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
for _, fallbackAPIServer := range app.FallbackAPIServers {
fallbackResponses := fallbackAPIServer.PlayerNamesToIDs(mapset.NewSet(lowerName))
if len(fallbackResponses) == 1 && strings.EqualFold(lowerName, fallbackResponses[0].Name) {
return c.JSON(http.StatusOK, fallbackResponses[0])
}
}
errorMessage := fmt.Sprintf("Couldn't find any profile with name %s", playerName)
return &YggdrasilError{Code: http.StatusNotFound, ErrorMessage: mo.Some(errorMessage)}
}
return result.Error
}
id, err := UUIDToID(player.UUID)
if err != nil {
return err
}
res := PlayerNameToIDResponse{
Name: player.Name,
ID: id,
}
return c.JSON(http.StatusOK, res)
}
}
// POST /profiles/minecraft
// POST /minecraft/profile/lookup/bulk/byname
// https://minecraft.wiki/w/Mojang_API#Query_player_UUIDs_in_batch
func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error {
return func(c echo.Context) error {
var playerNames []string
if err := json.NewDecoder(c.Request().Body).Decode(&playerNames); err != nil {
return err
}
if len(playerNames) == 0 {
// This error message is consistent with POST
// https://api.mojang.com/profiles/minecraft as of 2025-04-02
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some("getProfileName.profileNames: must not be empty"),
}
}
if len(playerNames) > MAX_PLAYER_NAMES_TO_IDS {
// This error message is consistent with POST
// https://api.mojang.com/profiles/minecraft as of 2025-04-02
errorMessage := fmt.Sprintf("getProfileName.profileNames: size must be between 0 and %d", MAX_PLAYER_NAMES_TO_IDS)
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some(errorMessage),
}
}
response := make([]PlayerNameToIDResponse, 0, len(playerNames))
remainingLowerNames := mapset.NewSet[string]()
for i, playerName := range playerNames {
if !(1 <= len(playerName) && len(playerName) <= Constants.MaxPlayerNameLength) {
// This error message is consistent with POST
// https://api.mojang.com/profiles/minecraft as of 2025-04-02
errorMessage := fmt.Sprintf("getProfileName.profileNames[%d].<list element>: size must be between 1 and %d, getProfileName.profileNames[%d].<list element>: Invalid profile name", i, Constants.MaxPlayerNameLength, 1)
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some(errorMessage),
}
}
lowerName := strings.ToLower(playerName)
if app.ValidatePlayerName(lowerName) != nil {
// This error message is consistent with POST
// https://api.mojang.com/profiles/minecraft as of 2025-04-03
errorMessage := fmt.Sprintf("getProfileName.profileNames[%d].<list element>: Invalid profile name", i)
return &YggdrasilError{
Code: http.StatusBadRequest,
Error_: mo.Some("CONSTRAINT_VIOLATION"),
ErrorMessage: mo.Some(errorMessage),
}
}
remainingLowerNames.Add(lowerName)
}
for _, lowerName := range remainingLowerNames.ToSlice() {
var player Player
result := app.DB.First(&player, "name = ?", lowerName)
if result.Error != nil {
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return result.Error
}
} else {
id, err := UUIDToID(player.UUID)
if err != nil {
return err
}
playerRes := PlayerNameToIDResponse{
Name: player.Name,
ID: id,
}
response = append(response, playerRes)
remainingLowerNames.Remove(lowerName)
}
}
for _, fallbackAPIServer := range app.FallbackAPIServers {
if remainingLowerNames.Cardinality() == 0 {
break
}
fallbackResponses := fallbackAPIServer.PlayerNamesToIDs(remainingLowerNames)
for _, fallbackResponse := range fallbackResponses {
lowerName := strings.ToLower(fallbackResponse.Name)
if remainingLowerNames.Contains(lowerName) {
response = append(response, fallbackResponse)
remainingLowerNames.Remove(lowerName)
}
}
}
return c.JSON(http.StatusOK, response)
}
}
// GET /user/security/location
func AccountVerifySecurityLocation(app *App) func(c echo.Context) error {
return func(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
}