Use flexible database for SAKE storage

This commit is contained in:
Palapeli 2026-04-01 00:38:31 -04:00
parent 713826f30e
commit 94c718f6d2
No known key found for this signature in database
GPG Key ID: 1FFE8F556A474925
7 changed files with 659 additions and 304 deletions

View File

@ -1,4 +1,16 @@
package common
import "reflect"
func UNUSED(v ...interface{}) {
}
func ReverseMap(m interface{}) interface{} {
inputType := reflect.TypeOf(m)
inputValue := reflect.ValueOf(m)
result := reflect.MakeMap(reflect.MapOf(inputType.Elem(), inputType.Key()))
for _, key := range inputValue.MapKeys() {
result.SetMapIndex(inputValue.MapIndex(key), key)
}
return result.Interface()
}

154
database/sake.go Normal file
View File

@ -0,0 +1,154 @@
package database
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgconn"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v4/pgxpool"
)
const (
SakeFieldTypeByte = 0
SakeFieldTypeShort = 1
SakeFieldTypeInt = 2
SakeFieldTypeFloat = 3
SakeFieldTypeAsciiString = 4
SakeFieldTypeUnicodeString = 5
SakeFieldTypeBoolean = 6
SakeFieldTypeDateAndTime = 7
SakeFieldTypeBinaryData = 8
SakeFieldTypeInt64 = 9
)
type SakeFieldType int
type SakeField struct {
Type SakeFieldType `json:"type"`
Value string `json:"value"`
}
type SakeRecord struct {
GameId int
OwnerId int32
TableId string
RecordId int32
Fields map[string]SakeField
}
const (
getSakeRecordsQuery = `
SELECT owner_id, record_id, fields
FROM sake_records
WHERE game_id = $1
AND table_id = $2
AND (cardinality($4::integer[]) = 0 OR record_id = ANY($4::integer[]))
AND (cardinality($3::integer[]) = 0 OR owner_id = ANY($3::integer[]))`
updateSakeRecordQuery = `
UPDATE sake_records
SET
fields = CASE WHEN owner_id = $4 THEN fields || $5 ELSE fields END,
update_time = CASE WHEN owner_id = $4 THEN CURRENT_TIMESTAMP ELSE update_time END
WHERE game_id = $1
AND table_id = $2
AND record_id = $3
RETURNING owner_id`
insertSakeRecordQuery = `
INSERT INTO sake_records (game_id, table_id, owner_id, fields)
VALUES ($1, $2, $3, $4)`
)
var (
ErrSakeNotOwned = errors.New("record is not owned by the specified owner ID")
)
func parseSakeFieldsFromJson(fieldsJson []byte) (map[string]SakeField, error) {
var fields map[string]SakeField
err := json.Unmarshal(fieldsJson, &fields)
if err != nil {
return nil, err
}
return fields, nil
}
func GetSakeRecords(pool *pgxpool.Pool, ctx context.Context, gameId int, ownerIds []int32, tableId string, recordIds []int32, fields []string, filter string) ([]SakeRecord, error) {
if fields == nil {
fields = []string{}
}
if ownerIds == nil {
ownerIds = []int32{}
}
if recordIds == nil {
recordIds = []int32{}
}
rows, err := pool.Query(ctx, getSakeRecordsQuery, gameId, tableId, ownerIds, recordIds)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SakeRecord
for rows.Next() {
record := SakeRecord{
GameId: gameId,
TableId: tableId,
}
var fieldsJson []byte
if err := rows.Scan(&record.OwnerId, &record.RecordId, &fieldsJson); err != nil {
return nil, err
}
fields, err := parseSakeFieldsFromJson(fieldsJson)
if err != nil {
return nil, err
}
record.Fields = fields
records = append(records, record)
}
return records, nil
}
func UpdateSakeRecord(pool *pgxpool.Pool, ctx context.Context, record SakeRecord, ownerId int32) error {
fieldsJson, err := json.Marshal(record.Fields)
if err != nil {
return err
}
var existingOwnerId int32
err = pool.QueryRow(ctx, updateSakeRecordQuery, record.GameId, record.TableId, record.RecordId, ownerId, fieldsJson).Scan(&existingOwnerId)
if err != nil {
return err
}
if ownerId != 0 && existingOwnerId != ownerId {
return ErrSakeNotOwned
}
return nil
}
func InsertSakeRecord(pool *pgxpool.Pool, ctx context.Context, record SakeRecord) error {
fieldsJson, err := json.Marshal(record.Fields)
if err != nil {
return err
}
for i := 0; i < 10; i++ {
_, err = pool.Exec(ctx, insertSakeRecordQuery, record.GameId, record.TableId, record.OwnerId, fieldsJson)
if err == nil {
break
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
break
}
if pgErr.Code != pgerrcode.UniqueViolation {
break
}
// Retry if unique violation occurred, as the record ID is generated randomly
}
return nil
}

8
go.mod
View File

@ -8,17 +8,21 @@ require (
github.com/jackc/pgx/v4 v4.18.3
github.com/logrusorgru/aurora/v3 v3.0.0
golang.org/x/net v0.47.0
github.com/linkdata/deadlock v0.5.5 // indirect
gvisor.dev/gvisor v0.0.0-20250512220230-2268d0cbb0f5
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgtype v1.14.4 // indirect
github.com/jackc/puddle v1.3.0 // indirect
github.com/linkdata/deadlock v0.5.5 // indirect
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/text v0.31.0 // indirect
gvisor.dev/gvisor v0.0.0-20250512220230-2268d0cbb0f5
)

6
go.sum
View File

@ -26,6 +26,8 @@ github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@ -78,6 +80,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/linkdata/deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU=
github.com/linkdata/deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U=
github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo=
github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0=
github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4=
@ -101,8 +105,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/linkdata/deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU=
github.com/linkdata/deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"wwfc/common"
@ -29,6 +30,150 @@ const (
rkgdFileName = "ghost.bin"
)
var (
ghostDataFilterRegex = regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687 and time < ([1-9][0-9]{0,5})$`)
)
func getMarioKartWiiGhostDataRecord(moduleName string, request StorageRequestData) (database.SakeRecord, bool) {
if request.Sort != "time desc" {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid sort string:", aurora.Cyan(request.Sort))
return database.SakeRecord{}, false
}
if request.Offset != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid offset value:", aurora.Cyan(request.Offset))
return database.SakeRecord{}, false
}
if request.Max != 1 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid number of records to return:", aurora.Cyan(request.Max))
return database.SakeRecord{}, false
}
if request.Surrounding != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding))
return database.SakeRecord{}, false
}
if len(request.OwnerIDs.OwnerID) != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid owner id array:", aurora.Cyan(request.OwnerIDs))
return database.SakeRecord{}, false
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag))
return database.SakeRecord{}, false
}
match := ghostDataFilterRegex.FindStringSubmatch(request.Filter)
if match == nil {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid filter string:", aurora.Cyan(request.Filter))
return database.SakeRecord{}, false
}
courseIdInt, _ := strconv.Atoi(match[1])
courseId := common.MarioKartWiiCourseId(courseIdInt)
if !courseId.IsValid() {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid course ID:", aurora.Cyan(match[1]))
return database.SakeRecord{}, false
}
time, _ := strconv.Atoi(match[2])
if time >= 360000 /* 6 minutes */ {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid time:", aurora.Cyan(match[2]))
return database.SakeRecord{}, false
}
fileId, err := database.GetMarioKartWiiGhostData(pool, ctx, courseId, time)
if err != nil {
logging.Error(moduleName, "mariokartwii/GhostData: Failed to get the ghost data from the database:", err)
return database.SakeRecord{}, false
}
return database.SakeRecord{
GameId: 1687,
TableId: "GhostData",
RecordId: 0,
OwnerId: 0,
Fields: map[string]database.SakeField{
"fileid": {
Type: database.SakeFieldTypeInt,
Value: strconv.FormatInt(int64(int32(fileId)), 10),
},
},
}, true
}
func getMarioKartWiiStoredGhostDataRecord(moduleName string, request StorageRequestData) (database.SakeRecord, bool) {
if request.Sort != "time" {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid sort string:", aurora.Cyan(request.Sort))
return database.SakeRecord{}, false
}
if request.Offset != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid offset value:", aurora.Cyan(request.Offset))
return database.SakeRecord{}, false
}
if request.Max != 1 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid number of records to return:", aurora.Cyan(request.Max))
return database.SakeRecord{}, false
}
if request.Surrounding != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding))
return database.SakeRecord{}, false
}
if len(request.OwnerIDs.OwnerID) != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid owner id array:", aurora.Cyan(request.OwnerIDs))
return database.SakeRecord{}, false
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag))
return database.SakeRecord{}, false
}
match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687(?: and region = ([1-7]))?$`).FindStringSubmatch(request.Filter)
if match == nil {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid filter string:", aurora.Cyan(request.Filter))
return database.SakeRecord{}, false
}
courseIdInt, _ := strconv.Atoi(match[1])
courseId := common.MarioKartWiiCourseId(courseIdInt)
if !courseId.IsValid() {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid course ID:", aurora.Cyan(match[1]))
return database.SakeRecord{}, false
}
var regionId common.MarioKartWiiLeaderboardRegionId
if regionIdExists := match[2] != ""; regionIdExists {
regionIdInt, _ := strconv.Atoi(match[2])
regionId = common.MarioKartWiiLeaderboardRegionId(regionIdInt)
} else {
regionId = common.Worldwide
}
pid, fileId, err := database.GetMarioKartWiiStoredGhostData(pool, ctx, regionId, courseId)
if err != nil {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Failed to get the stored ghost data from the database:", err)
return database.SakeRecord{}, false
}
return database.SakeRecord{
GameId: 1687,
TableId: "StoredGhostData",
RecordId: 0,
OwnerId: int32(pid),
Fields: map[string]database.SakeField{
"profile": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(pid)), 10)},
"fileid": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(fileId)), 10)},
},
}, true
}
func handleMarioKartWiiFileDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) {
if strings.HasSuffix(request.URL.Path, "ghostdownload.aspx") {
handleMarioKartWiiGhostDownloadRequest(moduleName, responseWriter, request)

View File

@ -1,20 +1,43 @@
package sake
import (
"encoding/base64"
"encoding/xml"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"time"
"wwfc/common"
"wwfc/database"
"wwfc/logging"
"github.com/jackc/pgx/v4"
"github.com/logrusorgru/aurora/v3"
)
const (
ResultSuccess = "Success"
ResultSecretKeyInvalid = "SecretKeyInvalid"
ResultServiceDisabled = "ServiceDisabled"
ResultDatabaseUnavailable = "DatabaseUnavailable"
ResultLoginTicketInvalid = "LoginTicketInvalid"
ResultLoginTicketExpired = "LoginTicketExpired"
ResultTableNotFound = "TableNotFound"
ResultRecordNotFound = "RecordNotFound"
ResultFieldNotFound = "FieldNotFound"
ResultFieldTypeInvalid = "FieldTypeInvalid"
ResultNoPermission = "NoPermission"
ResultRecordLimitReached = "RecordLimitReached"
ResultAlreadyRated = "AlreadyRated"
ResultNotRateable = "NotRateable"
ResultNotOwned = "NotOwned"
ResultFilterInvalid = "FilterInvalid"
ResultSortInvalid = "SortInvalid"
ResultTargetFilterInvalid = "TargetFilterInvalid"
ResultUnknownError = "UnknownError"
)
const (
SOAPEnvNamespace = "http://schemas.xmlsoap.org/soap/envelope/"
SakeNamespace = "http://gamespy.net/sake"
@ -36,13 +59,13 @@ type StorageRequestData struct {
SecretKey string `xml:"secretKey"`
LoginTicket string `xml:"loginTicket"`
TableID string `xml:"tableid"`
RecordID string `xml:"recordid"`
RecordID int32 `xml:"recordid"`
Filter string `xml:"filter"`
Sort string `xml:"sort"`
Offset int `xml:"offset"`
Max int `xml:"max"`
Surrounding int `xml:"surrounding"`
OwnerIDs string `xml:"ownerids"`
OwnerIDs StorageOwnerIDs `xml:"ownerids"`
CacheFlag int `xml:"cacheFlag"`
Fields StorageFields `xml:"fields"`
Values StorageUpdateRecordValues `xml:"values"`
@ -72,46 +95,68 @@ type StorageValue struct {
Value string `xml:"value"`
}
type StorageOwnerIDs struct {
OwnerID []int32 `xml:"int"`
}
type StorageResponseEnvelope struct {
XMLName xml.Name
Body StorageResponseBody
Body StorageResponseBody `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"`
}
type StorageResponseBody struct {
XMLName xml.Name
GetMyRecordsResponse *StorageGetMyRecordsResponse `xml:"http://gamespy.net/sake GetMyRecordsResponse"`
UpdateRecordResponse *StorageUpdateRecordResponse `xml:"http://gamespy.net/sake UpdateRecordResponse"`
SearchForRecordsResponse *StorageSearchForRecordsResponse `xml:"http://gamespy.net/sake SearchForRecordsResponse"`
}
type StorageGetMyRecordsResponse struct {
XMLName xml.Name
GetMyRecordsResult string
Values StorageResponseValues `xml:"values"` // ???
Values StorageResponseValues `xml:"values"`
}
type StorageResponseValues struct {
XMLName xml.Name
ArrayOfRecordValue StorageArrayOfRecordValue
ArrayOfRecordValue []StorageArrayOfRecordValue `xml:"ArrayOfRecordValue"`
}
type StorageArrayOfRecordValue struct {
XMLName xml.Name
RecordValues []StorageRecordValue `xml:"RecordValue"`
}
type StorageUpdateRecordResponse struct {
XMLName xml.Name
UpdateRecordResult string
// TODO
}
type StorageSearchForRecordsResponse struct {
XMLName xml.Name
SearchForRecordsResult string
Values StorageResponseValues `xml:"values"` // ???
Values StorageResponseValues `xml:"values"`
}
var (
sakeTypeToTag = map[database.SakeFieldType]string{
database.SakeFieldTypeByte: "byteValue",
database.SakeFieldTypeShort: "shortValue",
database.SakeFieldTypeInt: "intValue",
database.SakeFieldTypeFloat: "floatValue",
database.SakeFieldTypeAsciiString: "asciiStringValue",
database.SakeFieldTypeUnicodeString: "unicodeStringValue",
database.SakeFieldTypeBoolean: "booleanValue",
database.SakeFieldTypeDateAndTime: "dateAndTimeValue",
database.SakeFieldTypeBinaryData: "binaryDataValue",
database.SakeFieldTypeInt64: "int64Value",
}
tagToSakeType = common.ReverseMap(sakeTypeToTag).(map[string]database.SakeFieldType)
storageRequestHandlers = map[string]func(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody{
SakeNamespace + "/CreateRecord": createRecord,
SakeNamespace + "/UpdateRecord": updateRecord,
SakeNamespace + "/GetMyRecords": getMyRecords,
SakeNamespace + "/SearchForRecords": searchForRecords,
}
)
func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Request) {
headerAction := r.Header.Get("SOAPAction")
if headerAction == "" {
@ -124,6 +169,8 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ
panic(err)
}
logging.Info("SAKE", "Received storage request with SOAPAction:", aurora.Yellow(headerAction), "and body:", aurora.Cyan(string(body)))
// Parse the SOAP request XML
soap := StorageRequestEnvelope{}
err = xml.Unmarshal(body, &soap)
@ -134,29 +181,23 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ
response := StorageResponseEnvelope{
XMLName: xml.Name{Space: SOAPEnvNamespace, Local: "Envelope"},
Body: StorageResponseBody{
XMLName: xml.Name{Space: SOAPEnvNamespace, Local: "Body"},
},
}
xmlName := soap.Body.Data.XMLName.Space + "/" + soap.Body.Data.XMLName.Local
if headerAction == xmlName || headerAction == `"`+xmlName+`"` {
logging.Info(moduleName, "SOAPAction:", aurora.Yellow(soap.Body.Data.XMLName.Local))
if profileId, gameInfo, ok := getRequestIdentity(moduleName, soap.Body.Data); ok {
switch xmlName {
case SakeNamespace + "/GetMyRecords":
response.Body.GetMyRecordsResponse = getMyRecords(moduleName, profileId, gameInfo, soap.Body.Data)
handler, ok := storageRequestHandlers[xmlName]
if !ok {
panic("unknown SOAPAction: " + aurora.Cyan(xmlName).String())
}
case SakeNamespace + "/UpdateRecord":
response.Body.UpdateRecordResponse = updateRecord(moduleName, profileId, gameInfo, soap.Body.Data)
case SakeNamespace + "/SearchForRecords":
response.Body.SearchForRecordsResponse = searchForRecords(moduleName, gameInfo, soap.Body.Data)
default:
logging.Error(moduleName, "Unknown SOAPAction:", aurora.Cyan(xmlName))
}
profileId, gameInfo, errorString := getRequestIdentity(moduleName, soap.Body.Data)
if errorString != ResultSuccess {
logging.Error(moduleName, "Failed to get request identity:", aurora.Cyan(errorString))
response.Body.setResultTag(xmlName, errorString)
} else {
response.Body = handler(moduleName, profileId, gameInfo, soap.Body.Data)
}
} else {
logging.Error(moduleName, "Invalid SOAPAction or XML request:", aurora.Cyan(headerAction))
@ -174,316 +215,243 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ
w.Write(payload)
}
func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, common.GameInfo, bool) {
func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, common.GameInfo, string) {
gameInfo := common.GetGameInfoByID(request.GameID)
if gameInfo == nil {
logging.Error(moduleName, "Invalid game ID:", aurora.Cyan(request.GameID))
return 0, common.GameInfo{}, false
return 0, common.GameInfo{}, ResultDatabaseUnavailable
}
if gameInfo.SecretKey != request.SecretKey {
logging.Error(moduleName, "Mismatch", aurora.BrightCyan(gameInfo.Name), "secret key:", aurora.Cyan(request.SecretKey), "!=", aurora.BrightCyan(gameInfo.SecretKey))
return 0, common.GameInfo{}, false
return 0, common.GameInfo{}, ResultSecretKeyInvalid
}
profileId, _, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket)
profileId, issueTime, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket)
if err != nil {
logging.Error(moduleName, err)
return 0, common.GameInfo{}, false
return 0, common.GameInfo{}, ResultLoginTicketInvalid
}
logging.Info(moduleName, "Profile ID:", aurora.BrightCyan(profileId))
logging.Info(moduleName, "Game:", aurora.Cyan(request.GameID), "-", aurora.BrightCyan(gameInfo.Name))
logging.Info(moduleName, "Table ID:", aurora.Cyan(request.TableID))
if issueTime.Add(48 * time.Hour).Before(time.Now()) {
return 0, common.GameInfo{}, ResultLoginTicketExpired
}
return profileId, *gameInfo, true
return profileId, *gameInfo, ResultSuccess
}
func binaryDataValue(value []byte) StorageValue {
return StorageValue{
XMLName: xml.Name{Local: "binaryDataValue"},
Value: base64.StdEncoding.EncodeToString(value),
func getInputFields(moduleName string, request StorageRequestData) (map[string]database.SakeField, string) {
record := database.SakeRecord{
Fields: make(map[string]database.SakeField),
}
}
func binaryDataValueBase64(value string) StorageValue {
return StorageValue{
XMLName: xml.Name{Local: "binaryDataValue"},
Value: value,
}
}
func intValue(value int32) StorageValue {
return StorageValue{
XMLName: xml.Name{Local: "intValue"},
Value: strconv.FormatInt(int64(value), 10),
}
}
// I don't even know if this is a thing
func uintValue(value uint32) StorageValue {
return StorageValue{
XMLName: xml.Name{Local: "uintValue"},
Value: strconv.FormatUint(uint64(value), 10),
}
}
func getMyRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) *StorageGetMyRecordsResponse {
errorResponse := StorageGetMyRecordsResponse{
GetMyRecordsResult: "Error",
}
var values map[string]StorageValue
switch gameInfo.Name + "/" + request.TableID {
default:
logging.Error(moduleName, "Unknown table")
for _, field := range request.Fields.Fields {
logging.Info(moduleName, "Field:", aurora.Cyan(field))
for _, field := range request.Values.RecordFields {
fieldType, ok := tagToSakeType[field.Value.Value.XMLName.Local]
if !ok {
logging.Error(moduleName, "Invalid field type tag:", aurora.Cyan(field.Value.Value.XMLName.Local))
return nil, ResultFieldTypeInvalid
}
return &errorResponse
case "mariokartwii/FriendInfo":
// Mario Kart Wii friend info
values = map[string]StorageValue{
"ownerid": uintValue(profileId),
"recordid": intValue(int32(profileId)),
"info": binaryDataValueBase64(database.GetMKWFriendInfo(pool, ctx, profileId)),
if field.Name == "ownerid" || field.Name == "recordid" {
logging.Error(moduleName, "Attempt to set reserved field:", aurora.Cyan(field.Name))
return nil, ResultNoPermission
}
record.Fields[field.Name] = database.SakeField{Type: fieldType, Value: field.Value.Value.Value}
}
return record.Fields, ResultSuccess
}
func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody {
if request.TableID == "" {
logging.Error(moduleName, "No table ID provided")
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultTableNotFound,
}}
}
if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") {
// Reserved for special handler
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultTableNotFound,
}}
}
var record database.SakeRecord
var result string
record.Fields, result = getInputFields(moduleName, request)
if result != ResultSuccess {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: result,
}}
}
record.GameId = gameInfo.GameID
record.TableId = request.TableID
record.RecordId = 0
record.OwnerId = int32(profileId)
// TODO: Limit number of records or fields a user can have
err := database.InsertSakeRecord(pool, ctx, record)
if err != nil {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultDatabaseUnavailable,
}}
}
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultSuccess,
}}
}
func getMyRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody {
if len(request.Fields.Fields) == 0 {
// GameSpy client doesn't consider zero fields valid
return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{
GetMyRecordsResult: "BadNumFields",
}}
}
if request.TableID == "" {
logging.Error(moduleName, "No table ID provided")
return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{
GetMyRecordsResult: ResultTableNotFound,
}}
}
records, err := database.GetSakeRecords(pool, ctx, gameInfo.GameID, []int32{int32(profileId)}, request.TableID, nil, request.Fields.Fields, request.Filter)
if err != nil {
logging.Error(moduleName, "Failed to get sake records from the database:", err)
if err == pgx.ErrNoRows {
return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{
GetMyRecordsResult: ResultRecordNotFound,
}}
}
return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{
GetMyRecordsResult: ResultDatabaseUnavailable,
}}
}
response := StorageGetMyRecordsResponse{
GetMyRecordsResult: "Success",
GetMyRecordsResult: ResultSuccess,
Values: fillResponseValues(records, request),
}
logging.Info(moduleName, "Returning", aurora.Cyan(len(records)), "records from table", aurora.Cyan(request.TableID), "for profile", aurora.Cyan(profileId))
fieldCount := 0
valueArray := &response.Values.ArrayOfRecordValue
for _, field := range request.Fields.Fields {
if value, ok := values[field]; ok {
fieldCount++
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value})
} else {
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: nil})
}
}
logging.Info(moduleName, "Wrote", aurora.Cyan(fieldCount), "field(s)")
return &response
return StorageResponseBody{GetMyRecordsResponse: &response}
}
func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) *StorageUpdateRecordResponse {
errorResponse := StorageUpdateRecordResponse{
UpdateRecordResult: "Error",
func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody {
if request.TableID == "" {
logging.Error(moduleName, "No table ID provided")
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultTableNotFound,
}}
}
switch gameInfo.Name + "/" + request.TableID {
default:
logging.Error(moduleName, "Unknown table")
for _, field := range request.Values.RecordFields {
logging.Info(moduleName, "Field:", aurora.Cyan(field.Name), "Type:", aurora.Cyan(field.Value.XMLName.Local), "Value:", aurora.Cyan(field.Value.Value.Value))
if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") {
// Reserved for special handler
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultTableNotFound,
}}
}
var record database.SakeRecord
var result string
record.Fields, result = getInputFields(moduleName, request)
if result != ResultSuccess {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: result,
}}
}
record.GameId = gameInfo.GameID
record.TableId = request.TableID
record.RecordId = request.RecordID
record.OwnerId = int32(profileId)
err := database.UpdateSakeRecord(pool, ctx, record, int32(profileId))
if err != nil {
logging.Error(moduleName, "Failed to update sake record in the database:", err)
if err == database.ErrSakeNotOwned {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultNotOwned,
}}
}
return &errorResponse
case "mariokartwii/FriendInfo":
// Mario Kart Wii friend info
if len(request.Values.RecordFields) != 1 || request.Values.RecordFields[0].Name != "info" || request.Values.RecordFields[0].Value.Value.XMLName.Local != "binaryDataValue" {
logging.Error(moduleName, "Invalid record fields")
return &errorResponse
if err == pgx.ErrNoRows {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultRecordNotFound,
}}
}
// TODO: Validate record data
database.UpdateMKWFriendInfo(pool, ctx, profileId, request.Values.RecordFields[0].Value.Value.Value)
logging.Notice(moduleName, "Updated Mario Kart Wii friend info")
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultDatabaseUnavailable,
}}
}
return &StorageUpdateRecordResponse{
UpdateRecordResult: "Success",
}
logging.Info(moduleName, "Updated record", aurora.Cyan(record.RecordId), "in table", aurora.Cyan(record.TableId), "for profile", aurora.Cyan(profileId))
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultSuccess,
}}
}
func searchForRecords(moduleName string, gameInfo common.GameInfo, request StorageRequestData) *StorageSearchForRecordsResponse {
errorResponse := StorageSearchForRecordsResponse{
SearchForRecordsResult: "Error",
}
var searchRecordHandlers = map[string]func(string, StorageRequestData) (database.SakeRecord, bool){
"mariokartwii/GhostData": getMarioKartWiiGhostDataRecord,
"mariokartwii/StoredGhostData": getMarioKartWiiStoredGhostDataRecord,
}
var values []map[string]StorageValue
var (
standardFilterRegex = regexp.MustCompile(`^ownerid\s*=\s*-?(\d{1,10})$`)
)
switch gameInfo.Name + "/" + request.TableID {
default:
logging.Error(moduleName, "Unknown table")
for _, field := range request.Fields.Fields {
logging.Info(moduleName, "Field:", aurora.Cyan(field))
// TODO: Test this
func searchForRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody {
var records []database.SakeRecord
if handler, ok := searchRecordHandlers[gameInfo.Name+"/"+request.TableID]; ok {
record, ok := handler(moduleName, request)
if !ok {
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultUnknownError,
}}
}
return &errorResponse
records = append(records, record)
} else {
filter := request.Filter
ownerIds := request.OwnerIDs.OwnerID
case "mariokartwii/FriendInfo":
// Mario Kart Wii friend info
match := regexp.MustCompile(`^ownerid = (\d{1,10})$`).FindStringSubmatch(request.Filter)
if len(match) != 2 {
logging.Error(moduleName, "Invalid filter")
return &errorResponse
// Parse standard filter
if matches := standardFilterRegex.FindStringSubmatch(request.Filter); matches != nil {
ownerIdParsed, err := strconv.ParseInt(matches[1], 10, 32)
if err != nil {
logging.Error(moduleName, "Invalid owner ID in filter:", aurora.Cyan(request.Filter))
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultFilterInvalid,
}}
}
ownerIds = append(ownerIds, int32(ownerIdParsed))
filter = ""
}
ownerId, err := strconv.ParseInt(match[1], 10, 32)
var err error
records, err = database.GetSakeRecords(pool, ctx, gameInfo.GameID, ownerIds, request.TableID, nil, request.Fields.Fields, filter)
if err != nil {
logging.Error(moduleName, "Invalid owner ID")
return &errorResponse
}
values = []map[string]StorageValue{
{
"ownerid": uintValue(uint32(ownerId)),
"recordid": intValue(int32(ownerId)),
"info": binaryDataValueBase64(database.GetMKWFriendInfo(pool, ctx, uint32(ownerId))),
},
}
case "mariokartwii/GhostData":
if request.TableID != "GhostData" {
logging.Error(moduleName, "Invalid table name:", aurora.Cyan(request.TableID))
return &errorResponse
}
if request.Sort != "time desc" {
logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort))
return &errorResponse
}
if request.Offset != 0 {
logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset))
return &errorResponse
}
if request.Max != 1 {
logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max))
return &errorResponse
}
if request.Surrounding != 0 {
logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding))
return &errorResponse
}
if request.OwnerIDs != "" {
logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs))
return &errorResponse
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag))
return &errorResponse
}
match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687 and time < ([1-9][0-9]{0,5})$`).FindStringSubmatch(request.Filter)
if match == nil {
logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter))
return &errorResponse
}
courseIdInt, _ := strconv.Atoi(match[1])
courseId := common.MarioKartWiiCourseId(courseIdInt)
if !courseId.IsValid() {
logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1]))
return &errorResponse
}
time, _ := strconv.Atoi(match[2])
if time >= 360000 /* 6 minutes */ {
logging.Error(moduleName, "Invalid time:", aurora.Cyan(match[2]))
return &errorResponse
}
fileId, err := database.GetMarioKartWiiGhostData(pool, ctx, courseId, time)
if err != nil {
logging.Error(moduleName, "Failed to get the ghost data from the database:", err)
return &errorResponse
}
values = []map[string]StorageValue{
{
"fileid": intValue(int32(fileId)),
},
}
case "mariokartwii/StoredGhostData":
if request.Sort != "time" {
logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort))
return &errorResponse
}
if request.Offset != 0 {
logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset))
return &errorResponse
}
if request.Max != 1 {
logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max))
return &errorResponse
}
if request.Surrounding != 0 {
logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding))
return &errorResponse
}
if request.OwnerIDs != "" {
logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs))
return &errorResponse
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag))
return &errorResponse
}
match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687(?: and region = ([1-7]))?$`).FindStringSubmatch(request.Filter)
if match == nil {
logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter))
return &errorResponse
}
courseIdInt, _ := strconv.Atoi(match[1])
courseId := common.MarioKartWiiCourseId(courseIdInt)
if !courseId.IsValid() {
logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1]))
return &errorResponse
}
var regionId common.MarioKartWiiLeaderboardRegionId
if regionIdExists := match[2] != ""; regionIdExists {
regionIdInt, _ := strconv.Atoi(match[2])
regionId = common.MarioKartWiiLeaderboardRegionId(regionIdInt)
} else {
regionId = common.Worldwide
}
pid, fileId, err := database.GetMarioKartWiiStoredGhostData(pool, ctx, regionId, courseId)
if err != nil {
logging.Error(moduleName, "Failed to get the stored ghost data from the database:", err)
return &errorResponse
}
values = []map[string]StorageValue{
{
"profile": intValue(int32(pid)),
"fileid": intValue(int32(fileId)),
},
logging.Error(moduleName, "Failed to get sake records from the database:", err)
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultDatabaseUnavailable,
}}
}
}
// Sort the values now
sort.Slice(values, func(l, r int) bool {
lVal, lExists := values[l][request.Sort]
rVal, rExists := values[r][request.Sort]
// Sort the records now
sort.Slice(records, func(l, r int) bool {
lVal, lExists := records[l].Fields[request.Sort]
rVal, rExists := records[r].Fields[request.Sort]
if !lExists || !rExists {
// Prioritises the one that exists or goes left if both false
return rExists
}
if lVal.XMLName.Local != "intValue" && lVal.XMLName.Local != "uintValue" {
panic(aurora.Cyan(lVal.XMLName.Local).String() + " used as sort value")
if lVal.Type != database.SakeFieldTypeInt || rVal.Type != database.SakeFieldTypeInt {
panic(aurora.Cyan(lVal.Type).String() + " used as sort value")
}
// Assuming the two use the same type
lValInt, err := strconv.ParseInt(lVal.Value, 10, 64)
if err != nil {
@ -497,24 +465,79 @@ func searchForRecords(moduleName string, gameInfo common.GameInfo, request Stora
return lValInt < rValInt
})
// Enforce the maximum number of records after sorting
if request.Max > 0 && len(records) > request.Max {
records = records[:request.Max]
}
response := StorageSearchForRecordsResponse{
SearchForRecordsResult: "Success",
Values: fillResponseValues(records, request),
}
logging.Info(moduleName, "Searched for records in table", aurora.Cyan(request.TableID), "for profile", aurora.Cyan(profileId), "with filter", aurora.Cyan(request.Filter), "and got", aurora.Cyan(len(records)), "records")
fieldCount := 0
valueArray := &response.Values.ArrayOfRecordValue
var i int
for i = 0; i < len(values) && i < request.Max; i++ {
return StorageResponseBody{SearchForRecordsResponse: &response}
}
func fillResponseValues(records []database.SakeRecord, request StorageRequestData) StorageResponseValues {
var response StorageResponseValues
for _, record := range records {
valueArray := StorageArrayOfRecordValue{}
for _, field := range request.Fields.Fields {
if value, ok := values[i][field]; ok {
fieldCount++
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value})
} else {
if field == "ownerid" {
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &StorageValue{
XMLName: xml.Name{Local: "intValue"},
Value: strconv.FormatInt(int64(int32(record.OwnerId)), 10),
}})
continue
}
if field == "recordid" {
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &StorageValue{
XMLName: xml.Name{Local: "intValue"},
Value: strconv.FormatInt(int64(int32(record.RecordId)), 10),
}})
continue
}
var fieldValue *database.SakeField
for name, value := range record.Fields {
if name == field {
fieldValue = &value
break
}
}
if fieldValue == nil {
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: nil})
} else {
value := fillValue(fieldValue.Type, fieldValue.Value)
valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value})
}
}
response.ArrayOfRecordValue = append(response.ArrayOfRecordValue, valueArray)
}
return response
}
func fillValue(valueType database.SakeFieldType, value string) StorageValue {
return StorageValue{
XMLName: xml.Name{Local: sakeTypeToTag[valueType]},
Value: value,
}
}
func (body *StorageResponseBody) setResultTag(xmlName string, result string) {
switch xmlName {
case SakeNamespace + "/GetMyRecords":
body.GetMyRecordsResponse = &StorageGetMyRecordsResponse{
GetMyRecordsResult: result,
}
case SakeNamespace + "/UpdateRecord":
body.UpdateRecordResponse = &StorageUpdateRecordResponse{
UpdateRecordResult: result,
}
case SakeNamespace + "/SearchForRecords":
body.SearchForRecordsResponse = &StorageSearchForRecordsResponse{}
}
logging.Info(moduleName, "Wrote", aurora.BrightCyan(fieldCount), "field(s) across", aurora.BrightCyan(i), "record(s)")
return &response
}

View File

@ -63,6 +63,21 @@ END $$;
ALTER TABLE public.users OWNER TO wiilink;
--
-- Name: sake_records; Type: TABLE; Schema: public; Owner: wiilink
--
CREATE TABLE IF NOT EXISTS public.sake_records (
game_id integer NOT NULL,
table_id character varying NOT NULL,
record_id integer NOT NULL DEFAULT (random() * 2147483647)::integer,
owner_id integer NOT NULL,
fields jsonb NOT NULL,
create_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
update_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT one_sake_record_constraint UNIQUE (game_id, table_id, record_id)
);
--
-- Name: mario_kart_wii_sake; Type: TABLE; Schema: public; Owner: wiilink
--