mirror of
https://github.com/unmojang/drasl.git
synced 2025-08-03 02:46:03 -04:00
Batch and rate-limit POST /profiles/minecraft to fallback API servers
For https://github.com/unmojang/drasl/issues/112
This commit is contained in:
parent
4339886e8f
commit
770ceededb
302
account.go
302
account.go
@ -1,23 +1,182 @@
|
||||
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"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type playerNameToUUIDResponse struct {
|
||||
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 (fallbackAPIServer *FallbackAPIServer) PlayerNamesToIDsWorker() {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -25,39 +184,27 @@ 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)
|
||||
|
||||
var player Player
|
||||
result := app.DB.First(&player, "name = ?", playerName)
|
||||
result := app.DB.First(&player, "name = ?", lowerName)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
for _, fallbackAPIServer := range app.Config.FallbackAPIServers {
|
||||
reqURL, err := url.JoinPath(fallbackAPIServer.AccountURL, "profiles/minecraft")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
payload := []string{playerName}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := app.CachedPostJSON(reqURL, body, fallbackAPIServer.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 fallbackResponses []playerNameToUUIDResponse
|
||||
err = json.Unmarshal(res.BodyBytes, &fallbackResponses)
|
||||
if err != nil {
|
||||
log.Printf("Received invalid response from fallback API server at %s\n", reqURL)
|
||||
continue
|
||||
}
|
||||
if len(fallbackResponses) == 1 && strings.EqualFold(playerName, fallbackResponses[0].Name) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@ -71,7 +218,7 @@ func AccountPlayerNameToID(app *App) func(c echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := playerNameToUUIDResponse{
|
||||
res := PlayerNameToIDResponse{
|
||||
Name: player.Name,
|
||||
ID: id,
|
||||
}
|
||||
@ -90,25 +237,48 @@ func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
n := len(playerNames)
|
||||
if !(1 <= n && n <= 10) {
|
||||
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("getProfileName.profileNames: size must be between 1 and 10"),
|
||||
ErrorMessage: mo.Some(errorMessage),
|
||||
}
|
||||
} else 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([]playerNameToUUIDResponse, 0, n)
|
||||
response := make([]PlayerNameToIDResponse, 0, len(playerNames))
|
||||
|
||||
remainingPlayers := map[string]bool{}
|
||||
for _, playerName := range 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),
|
||||
}
|
||||
}
|
||||
remainingLowerNames.Add(strings.ToLower(playerName))
|
||||
}
|
||||
|
||||
for _, lowerName := range remainingLowerNames.ToSlice() {
|
||||
var player Player
|
||||
result := app.DB.First(&player, "name = ?", playerName)
|
||||
result := app.DB.First(&player, "name = ?", lowerName)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
remainingPlayers[strings.ToLower(playerName)] = true
|
||||
} else {
|
||||
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
} else {
|
||||
@ -116,58 +286,28 @@ func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playerRes := playerNameToUUIDResponse{
|
||||
playerRes := PlayerNameToIDResponse{
|
||||
Name: player.Name,
|
||||
ID: id,
|
||||
}
|
||||
response = append(response, playerRes)
|
||||
remainingLowerNames.Remove(lowerName)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fallbackAPIServer := range app.Config.FallbackAPIServers {
|
||||
reqURL, err := url.JoinPath(fallbackAPIServer.AccountURL, "profiles/minecraft")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
payload := make([]string, 0, len(remainingPlayers))
|
||||
for remainingPlayer := range remainingPlayers {
|
||||
payload = append(payload, remainingPlayer)
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := app.CachedPostJSON(reqURL, body, fallbackAPIServer.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 fallbackResponses []playerNameToUUIDResponse
|
||||
err = json.Unmarshal(res.BodyBytes, &fallbackResponses)
|
||||
if err != nil {
|
||||
log.Printf("Received invalid response from fallback API server at %s\n", reqURL)
|
||||
continue
|
||||
for _, fallbackAPIServer := range app.FallbackAPIServers {
|
||||
if remainingLowerNames.Cardinality() == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
fallbackResponses := fallbackAPIServer.PlayerNamesToIDs(remainingLowerNames)
|
||||
for _, fallbackResponse := range fallbackResponses {
|
||||
lowerName := strings.ToLower(fallbackResponse.Name)
|
||||
if _, ok := remainingPlayers[lowerName]; ok {
|
||||
if remainingLowerNames.Contains(lowerName) {
|
||||
response = append(response, fallbackResponse)
|
||||
delete(remainingPlayers, lowerName)
|
||||
remainingLowerNames.Remove(lowerName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remainingPlayers) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"testing"
|
||||
@ -29,11 +30,14 @@ func TestAccount(t *testing.T) {
|
||||
ts.SetupAux(auxConfig)
|
||||
|
||||
config := testConfig()
|
||||
config.FallbackAPIServers = []FallbackAPIServer{ts.ToFallbackAPIServer(ts.AuxApp, "Aux")}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{ts.ToFallbackAPIServer(ts.AuxApp, "Aux")}
|
||||
ts.Setup(config)
|
||||
defer ts.Teardown()
|
||||
|
||||
ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_USERNAME)
|
||||
for i := 1; i <= 20; i += 1 {
|
||||
ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, fmt.Sprintf("%s%d", TEST_USERNAME, i))
|
||||
}
|
||||
|
||||
t.Run("Test /users/profiles/minecraft/:playerName, fallback API server", ts.testAccountPlayerNameToIDFallback)
|
||||
t.Run("Test /profile/minecraft, fallback API server", ts.testAccountPlayerNamesToIDsFallback)
|
||||
@ -44,7 +48,7 @@ func (ts *TestSuite) testAccountPlayerNameToID(t *testing.T) {
|
||||
rec := ts.Get(t, ts.Server, "/users/profiles/minecraft/"+TEST_PLAYER_NAME, nil, nil)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response playerNameToUUIDResponse
|
||||
var response PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
|
||||
// Check that the player name is correct
|
||||
@ -72,7 +76,7 @@ func (ts *TestSuite) testAccountPlayerNameToIDFallback(t *testing.T) {
|
||||
{
|
||||
rec := ts.Get(t, ts.Server, "/users/profiles/minecraft/"+TEST_PLAYER_NAME, nil, nil)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response playerNameToUUIDResponse
|
||||
var response PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
|
||||
// Check that the player name is correct
|
||||
@ -110,7 +114,7 @@ func (ts *TestSuite) testAccountPlayerNameToIDFallback(t *testing.T) {
|
||||
|
||||
// Test a non-existent user
|
||||
{
|
||||
rec := ts.Get(t, ts.Server, "/users/profiles/minecraft/", nil, nil)
|
||||
rec := ts.Get(t, ts.Server, "/users/profiles/minecraft/nonexistent", nil, nil)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
@ -121,7 +125,7 @@ func (ts *TestSuite) testAccountPlayerNamesToIDsFallback(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/profiles/minecraft", payload, nil, nil)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response []playerNameToUUIDResponse
|
||||
var response []PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
|
||||
// Get the real UUID
|
||||
@ -132,7 +136,7 @@ func (ts *TestSuite) testAccountPlayerNamesToIDsFallback(t *testing.T) {
|
||||
// There should only be one player, the nonexistent player should not be present
|
||||
id, err := UUIDToID(player.UUID)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []playerNameToUUIDResponse{{Name: TEST_PLAYER_NAME, ID: id}}, response)
|
||||
assert.Equal(t, []PlayerNameToIDResponse{{Name: TEST_PLAYER_NAME, ID: id}}, response)
|
||||
}
|
||||
{
|
||||
payload := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}
|
||||
@ -144,6 +148,39 @@ func (ts *TestSuite) testAccountPlayerNamesToIDsFallback(t *testing.T) {
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "CONSTRAINT_VIOLATION", *response.Error)
|
||||
}
|
||||
{
|
||||
// Test multiple batches
|
||||
{
|
||||
payload := make([]string, 0)
|
||||
for i := 1; i <= 10; i += 1 {
|
||||
payload = append(payload, fmt.Sprintf("%s%d", TEST_PLAYER_NAME, i))
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, "/profiles/minecraft", payload, nil, nil)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response []PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
}
|
||||
{
|
||||
payload := make([]string, 0)
|
||||
for i := 11; i <= 15; i += 1 {
|
||||
payload = append(payload, fmt.Sprintf("%s%d", TEST_PLAYER_NAME, i))
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, "/profiles/minecraft", payload, nil, nil)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response []PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
}
|
||||
{
|
||||
payload := make([]string, 0)
|
||||
for i := 16; i <= 20; i += 1 {
|
||||
payload = append(payload, fmt.Sprintf("%s%d", TEST_PLAYER_NAME, i))
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, "/profiles/minecraft", payload, nil, nil)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response []PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testAccountVerifySecurityLocation(t *testing.T) {
|
||||
|
@ -40,7 +40,7 @@ func TestAuthlibInjector(t *testing.T) {
|
||||
config := testConfig()
|
||||
fallback := ts.ToFallbackAPIServer(ts.AuxApp, "Aux")
|
||||
fallback.SkinDomains = []string{FALLBACK_SKIN_DOMAIN_A, FALLBACK_SKIN_DOMAIN_B}
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fallback}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fallback}
|
||||
ts.Setup(config)
|
||||
defer ts.Teardown()
|
||||
|
||||
|
29
common.go
29
common.go
@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/dgraph-io/ristretto"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
@ -28,6 +29,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const MAX_PLAYER_NAMES_TO_IDS = 10
|
||||
const MAX_PLAYER_NAMES_TO_IDS_INTERVAL = 1 * time.Second
|
||||
|
||||
func (app *App) AEADEncrypt(plaintext []byte) ([]byte, error) {
|
||||
nonceSize := app.AEAD.NonceSize()
|
||||
|
||||
@ -710,6 +714,7 @@ func (app *App) GetFallbackSkinTexturesProperty(player *Player) (*SessionProfile
|
||||
} else {
|
||||
// Otherwise, we only know the player name. Query the fallback API
|
||||
// server to get the fallback player's UUID
|
||||
// TODO this should POST /profiles/minecraft instead to be authlib-injector-compatible
|
||||
reqURL, err := url.JoinPath(fallbackAPIServer.AccountURL, "/users/profiles/minecraft/", fallbackPlayer)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
@ -726,7 +731,7 @@ func (app *App) GetFallbackSkinTexturesProperty(player *Player) (*SessionProfile
|
||||
continue
|
||||
}
|
||||
|
||||
var playerResponse playerNameToUUIDResponse
|
||||
var playerResponse PlayerNameToIDResponse
|
||||
err = json.Unmarshal(res.BodyBytes, &playerResponse)
|
||||
if err != nil {
|
||||
log.Printf("Received invalid response from fallback API server at %s\n", reqURL)
|
||||
@ -956,3 +961,25 @@ func (app *App) GetSkinTexturesProperty(player *Player, sign bool) (SessionProfi
|
||||
func MakeHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
type FallbackAPIServer struct {
|
||||
Config *FallbackAPIServerConfig
|
||||
PlayerNameToIDCache *ristretto.Cache
|
||||
PlayerNameToIDJobCh chan []playerNameToIDJob
|
||||
}
|
||||
|
||||
func NewFallbackAPIServer(config *FallbackAPIServerConfig) (FallbackAPIServer, error) {
|
||||
var playerNameToIDCache *ristretto.Cache = nil
|
||||
if config.CacheTTLSeconds > 0 {
|
||||
var err error
|
||||
playerNameToIDCache, err = ristretto.NewCache(DefaultRistrettoConfig)
|
||||
if err != nil {
|
||||
return FallbackAPIServer{}, err
|
||||
}
|
||||
}
|
||||
return FallbackAPIServer{
|
||||
Config: config,
|
||||
PlayerNameToIDCache: playerNameToIDCache,
|
||||
PlayerNameToIDJobCh: make(chan []playerNameToIDJob),
|
||||
}, nil
|
||||
}
|
||||
|
18
config.go
18
config.go
@ -28,7 +28,7 @@ type bodyLimitConfig struct {
|
||||
SizeLimitKiB int
|
||||
}
|
||||
|
||||
type FallbackAPIServer struct {
|
||||
type FallbackAPIServerConfig struct {
|
||||
Nickname string
|
||||
SessionURL string
|
||||
AccountURL string
|
||||
@ -114,7 +114,7 @@ type Config struct {
|
||||
EnableBackgroundEffect bool
|
||||
EnableFooter bool
|
||||
EnableWebFrontEnd bool
|
||||
FallbackAPIServers []FallbackAPIServer
|
||||
FallbackAPIServers []FallbackAPIServerConfig
|
||||
ForwardSkins bool
|
||||
InstanceName string
|
||||
ImportExistingPlayer importExistingPlayerConfig
|
||||
@ -146,6 +146,13 @@ var defaultBodyLimitConfig = bodyLimitConfig{
|
||||
SizeLimitKiB: 8192,
|
||||
}
|
||||
|
||||
var DefaultRistrettoConfig = &ristretto.Config{
|
||||
// Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config
|
||||
NumCounters: 1e7,
|
||||
MaxCost: 1 << 30, // 1 GiB
|
||||
BufferItems: 64,
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
AllowCapes: true,
|
||||
@ -190,12 +197,7 @@ func DefaultConfig() Config {
|
||||
Allow: true,
|
||||
RequireInvite: false,
|
||||
},
|
||||
RequestCache: ristretto.Config{
|
||||
// Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config
|
||||
NumCounters: 1e7,
|
||||
MaxCost: 1 << 30, // 1 GiB
|
||||
BufferItems: 64,
|
||||
},
|
||||
RequestCache: *DefaultRistrettoConfig,
|
||||
SignPublicKeys: true,
|
||||
SkinSizeLimit: 64,
|
||||
StateDirectory: GetDefaultStateDirectory(),
|
||||
|
@ -92,7 +92,7 @@ func TestConfig(t *testing.T) {
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
config = configTestConfig(sd)
|
||||
testFallbackAPIServer := FallbackAPIServer{
|
||||
testFallbackAPIServer := FallbackAPIServerConfig{
|
||||
Nickname: "Nickname",
|
||||
SessionURL: "https://δρασλ.example.com/",
|
||||
AccountURL: "https://δρασλ.example.com/",
|
||||
@ -100,10 +100,10 @@ func TestConfig(t *testing.T) {
|
||||
SkinDomains: []string{"δρασλ.example.com"},
|
||||
}
|
||||
fb := testFallbackAPIServer
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.Nil(t, CleanConfig(config))
|
||||
|
||||
assert.Equal(t, []FallbackAPIServer{{
|
||||
assert.Equal(t, []FallbackAPIServerConfig{{
|
||||
Nickname: fb.Nickname,
|
||||
SessionURL: "https://xn--mxafwwl.example.com",
|
||||
AccountURL: "https://xn--mxafwwl.example.com",
|
||||
@ -113,37 +113,37 @@ func TestConfig(t *testing.T) {
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.Nickname = ""
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.SessionURL = ""
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.SessionURL = ":invalid URL"
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.AccountURL = ""
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.AccountURL = ":invalid URL"
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.ServicesURL = ""
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
fb = testFallbackAPIServer
|
||||
fb.ServicesURL = ":invalid URL"
|
||||
config.FallbackAPIServers = []FallbackAPIServer{fb}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{fb}
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
// Test that TEMPLATE_CONFIG_FILE is valid
|
||||
|
@ -48,7 +48,7 @@
|
||||
];
|
||||
|
||||
# Update whenever Go dependencies change
|
||||
vendorHash = "sha256-jthuA1MlP83sXYuZHX6MwD33JfhjrFPax5B+26iLh20=";
|
||||
vendorHash = "sha256-iGOYsgrOwx3nbvlc3ln6awg23CZBdtaqQbYY30q25dU=";
|
||||
|
||||
outputs = ["out"];
|
||||
|
||||
|
@ -44,7 +44,7 @@ func setupRegistrationExistingPlayerTS(t *testing.T, requireSkinVerification boo
|
||||
AccountURL: ts.AuxApp.AccountURL,
|
||||
RequireSkinVerification: requireSkinVerification,
|
||||
}
|
||||
config.FallbackAPIServers = []FallbackAPIServer{
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{
|
||||
{
|
||||
Nickname: "Aux",
|
||||
SessionURL: ts.AuxApp.SessionURL,
|
||||
|
2
go.mod
2
go.mod
@ -6,7 +6,7 @@ toolchain go1.23.2
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2
|
||||
github.com/deckarep/golang-set/v2 v2.6.0
|
||||
github.com/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/dgraph-io/ristretto v0.1.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
|
4
go.sum
4
go.sum
@ -9,8 +9,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
|
22
main.go
22
main.go
@ -70,6 +70,7 @@ type App struct {
|
||||
OIDCProviderNames []string
|
||||
OIDCProvidersByName map[string]*OIDCProvider
|
||||
OIDCProvidersByIssuer map[string]*OIDCProvider
|
||||
FallbackAPIServers []FallbackAPIServer
|
||||
}
|
||||
|
||||
func LogInfo(args ...interface{}) {
|
||||
@ -453,14 +454,15 @@ func setup(config *Config) *App {
|
||||
log.Fatal("Invalid verification skin!")
|
||||
}
|
||||
|
||||
// Keys
|
||||
// Keys, FallbackAPIServers
|
||||
fallbackAPIServers := make([]FallbackAPIServer, 0, len(config.FallbackAPIServers))
|
||||
playerCertificateKeys := make([]rsa.PublicKey, 0, 1)
|
||||
profilePropertyKeys := make([]rsa.PublicKey, 0, 1)
|
||||
profilePropertyKeys = append(profilePropertyKeys, key.PublicKey)
|
||||
playerCertificateKeys = append(playerCertificateKeys, key.PublicKey)
|
||||
|
||||
for _, fallbackAPIServer := range config.FallbackAPIServers {
|
||||
reqURL := Unwrap(url.JoinPath(fallbackAPIServer.ServicesURL, "publickeys"))
|
||||
for _, fallbackAPIServerConfig := range config.FallbackAPIServers {
|
||||
reqURL := Unwrap(url.JoinPath(fallbackAPIServerConfig.ServicesURL, "publickeys"))
|
||||
res, err := MakeHTTPClient().Get(reqURL)
|
||||
if err != nil {
|
||||
log.Printf("Couldn't access fallback API server at %s: %s\n", reqURL, err)
|
||||
@ -500,7 +502,10 @@ func setup(config *Config) *App {
|
||||
playerCertificateKeys = append(playerCertificateKeys, *publicKey)
|
||||
}
|
||||
}
|
||||
log.Printf("Fetched public keys from fallback API server %s", fallbackAPIServer.Nickname)
|
||||
log.Printf("Fetched public keys from fallback API server %s", fallbackAPIServerConfig.Nickname)
|
||||
|
||||
fallbackAPIServer := Unwrap(NewFallbackAPIServer(&fallbackAPIServerConfig))
|
||||
fallbackAPIServers = append(fallbackAPIServers, fallbackAPIServer)
|
||||
}
|
||||
|
||||
// OIDC providers
|
||||
@ -564,6 +569,7 @@ func setup(config *Config) *App {
|
||||
OIDCProviderNames: oidcProviderNames,
|
||||
OIDCProvidersByName: oidcProvidersByName,
|
||||
OIDCProvidersByIssuer: oidcProvidersByIssuer,
|
||||
FallbackAPIServers: fallbackAPIServers,
|
||||
}
|
||||
|
||||
// Post-setup
|
||||
@ -604,6 +610,12 @@ func setup(config *Config) *App {
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *App) Run() {
|
||||
for _, fallbackAPIServer := range PtrSlice(app.FallbackAPIServers) {
|
||||
go (*fallbackAPIServer).PlayerNamesToIDsWorker()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
defaultConfigPath := path.Join(Constants.ConfigDirectory, "config.toml")
|
||||
|
||||
@ -623,6 +635,6 @@ func main() {
|
||||
log.Fatalf("Error in config: %s", err)
|
||||
}
|
||||
app := setup(&config)
|
||||
|
||||
go app.Run()
|
||||
Check(app.MakeServer().Start(app.Config.ListenAddress))
|
||||
}
|
||||
|
@ -398,7 +398,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
|
||||
return nil, errors.New("registration server returned error")
|
||||
}
|
||||
|
||||
var idRes playerNameToUUIDResponse
|
||||
var idRes PlayerNameToIDResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&idRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -27,7 +27,7 @@ func TestServices(t *testing.T) {
|
||||
|
||||
config := testConfig()
|
||||
config.ForwardSkins = false
|
||||
config.FallbackAPIServers = []FallbackAPIServer{
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{
|
||||
{
|
||||
Nickname: "Aux",
|
||||
SessionURL: ts.AuxApp.SessionURL,
|
||||
@ -502,7 +502,7 @@ func (ts *TestSuite) makeTestAccountPlayerNamesToIDs(url string) func(t *testing
|
||||
ts.Server.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response []playerNameToUUIDResponse
|
||||
var response []PlayerNameToIDResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
|
||||
// Get the real UUID
|
||||
@ -513,6 +513,6 @@ func (ts *TestSuite) makeTestAccountPlayerNamesToIDs(url string) func(t *testing
|
||||
assert.Nil(t, err)
|
||||
|
||||
// There should only be one user, the nonexistent user should not be present
|
||||
assert.Equal(t, []playerNameToUUIDResponse{{Name: TEST_USERNAME, ID: id}}, response)
|
||||
assert.Equal(t, []PlayerNameToIDResponse{{Name: TEST_USERNAME, ID: id}}, response)
|
||||
}
|
||||
}
|
||||
|
@ -90,8 +90,10 @@ func (ts *TestSuite) Setup(config *Config) {
|
||||
tsConfig := *config
|
||||
ts.Config = &tsConfig
|
||||
ts.App = setup(config)
|
||||
ts.Server = ts.App.MakeServer()
|
||||
|
||||
go ts.App.Run()
|
||||
|
||||
ts.Server = ts.App.MakeServer()
|
||||
go func() { Ignore(ts.Server.Start("")) }()
|
||||
}
|
||||
|
||||
@ -105,8 +107,10 @@ func (ts *TestSuite) SetupAux(config *Config) {
|
||||
auxConfig := *config
|
||||
ts.AuxConfig = &auxConfig
|
||||
ts.AuxApp = setup(config)
|
||||
ts.AuxServer = ts.AuxApp.MakeServer()
|
||||
|
||||
go ts.AuxApp.Run()
|
||||
|
||||
ts.AuxServer = ts.AuxApp.MakeServer()
|
||||
go func() { Ignore(ts.AuxServer.Start("")) }()
|
||||
|
||||
// Wait until the server has a listen address... polling seems like the
|
||||
@ -127,13 +131,13 @@ func (ts *TestSuite) SetupAux(config *Config) {
|
||||
ts.AuxApp.SessionURL = Unwrap(url.JoinPath(baseURL, "session"))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) ToFallbackAPIServer(app *App, nickname string) FallbackAPIServer {
|
||||
return FallbackAPIServer{
|
||||
func (ts *TestSuite) ToFallbackAPIServer(app *App, nickname string) FallbackAPIServerConfig {
|
||||
return FallbackAPIServerConfig{
|
||||
Nickname: nickname,
|
||||
SessionURL: app.SessionURL,
|
||||
AccountURL: app.AccountURL,
|
||||
ServicesURL: app.ServicesURL,
|
||||
CacheTTLSeconds: 3600,
|
||||
CacheTTLSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,7 +328,7 @@ func testConfig() *Config {
|
||||
config.Domain = "drasl.example.com"
|
||||
noRateLimit := rateLimitConfig{Enable: false}
|
||||
config.RateLimit = noRateLimit
|
||||
config.FallbackAPIServers = []FallbackAPIServer{}
|
||||
config.FallbackAPIServers = []FallbackAPIServerConfig{}
|
||||
config.LogRequests = false
|
||||
return &config
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user