SAKE: Create game-specific table definitions
Some checks failed
Build CI / build (push) Has been cancelled

Additionally adds verification and sanity checks for all values passed to Sake from the client
This commit is contained in:
Palapeli 2026-04-03 00:22:15 -04:00
parent d5c1a80a06
commit 865d8a8df2
No known key found for this signature in database
GPG Key ID: 1FFE8F556A474925
5 changed files with 508 additions and 131 deletions

View File

@ -2,20 +2,13 @@ package common
import (
"encoding/base64"
"errors"
"strings"
)
type GameSpyBase64Encoding int
const (
GameSpyBase64EncodingDefault = iota // 0
GameSpyBase64EncodingAlternate // 1
GameSpyBase64EncodingURLSafe // 2
var (
Base64DwcEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-").WithPadding('*')
Base64GamespyAlternativeEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789[]").WithPadding('_')
)
var Base64DwcEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-").WithPadding('*')
func Base32Encode(value uint64) string {
alpha := "0123456789abcdefghijklmnopqrstuv"
@ -30,29 +23,13 @@ func Base32Encode(value uint64) string {
return encoded
}
func DecodeGameSpyBase64(gameSpyBase64 string, gameSpyBase64Encoding GameSpyBase64Encoding) ([]byte, error) {
base64String, err := GameSpyBase64ToBase64(gameSpyBase64, gameSpyBase64Encoding)
func Base64Convert(input string, fromEncoding, toEncoding *base64.Encoding) (string, error) {
decoded, err := fromEncoding.DecodeString(input)
if err != nil {
return nil, err
return "", err
}
return base64.StdEncoding.DecodeString(base64String)
}
func GameSpyBase64ToBase64(gameSpyBase64 string, gameSpyBase64Encoding GameSpyBase64Encoding) (string, error) {
switch gameSpyBase64Encoding {
case GameSpyBase64EncodingDefault:
return gameSpyBase64, nil
case GameSpyBase64EncodingAlternate:
return strings.NewReplacer("[", "+", "]", "/", "_", "=").Replace(gameSpyBase64), nil
case GameSpyBase64EncodingURLSafe:
return strings.NewReplacer("-", "+", "_", "/" /*, "=", "="*/).Replace(gameSpyBase64), nil
default:
return "", errors.New("invalid GameSpy Base64 encoding specified")
}
return toEncoding.EncodeToString(decoded), nil
}
func reverse(s string) string {

View File

@ -14,3 +14,25 @@ const (
SakeFileResultFileTooLarge = 5
SakeFileResultServerError = 6
)
const (
ResultSuccess = "Success" // 4xx51
ResultSecretKeyInvalid = "SecretKeyInvalid" // 4xx52
ResultServiceDisabled = "ServiceDisabled" // 4xx53
ResultDatabaseUnavailable = "DatabaseUnavailable" // 4xx58
ResultLoginTicketInvalid = "LoginTicketInvalid" // 4xx59
ResultLoginTicketExpired = "LoginTicketExpired" // 4xx60
ResultTableNotFound = "TableNotFound" // 4xx61
ResultRecordNotFound = "RecordNotFound" // 4xx62
ResultFieldNotFound = "FieldNotFound" // 4xx63
ResultFieldTypeInvalid = "FieldTypeInvalid" // 4xx64
ResultNoPermission = "NoPermission" // 4xx65
ResultRecordLimitReached = "RecordLimitReached" // 4xx66
ResultAlreadyRated = "AlreadyRated" // 4xx67
ResultNotRateable = "NotRateable" // 4xx68
ResultNotOwned = "NotOwned" // 4xx69
ResultFilterInvalid = "FilterInvalid" // 4xx70
ResultSortInvalid = "SortInvalid" // 4xx71
ResultTargetFilterInvalid = "TargetFilterInvalid" // 4xx80
ResultUnknownError = "UnknownError" // 4xx72
)

View File

@ -2,6 +2,7 @@ package sake
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
@ -34,63 +35,63 @@ 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) {
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
return []database.SakeRecord{}, false
}
if request.Offset != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid offset value:", aurora.Cyan(request.Offset))
return database.SakeRecord{}, false
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
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
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
return []database.SakeRecord{}, false
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "mariokartwii/GhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag))
return database.SakeRecord{}, false
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
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
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
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{}, false
}
return database.SakeRecord{
return []database.SakeRecord{{
GameId: 1687,
TableId: "GhostData",
RecordId: 0,
@ -101,51 +102,51 @@ func getMarioKartWiiGhostDataRecord(moduleName string, request StorageRequestDat
Value: strconv.FormatInt(int64(int32(fileId)), 10),
},
},
}, true
}}, true
}
func getMarioKartWiiStoredGhostDataRecord(moduleName string, request StorageRequestData) (database.SakeRecord, bool) {
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
return []database.SakeRecord{}, false
}
if request.Offset != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid offset value:", aurora.Cyan(request.Offset))
return database.SakeRecord{}, false
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
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
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
return []database.SakeRecord{}, false
}
if request.CacheFlag != 0 {
logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag))
return database.SakeRecord{}, false
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
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
return []database.SakeRecord{}, false
}
var regionId common.MarioKartWiiLeaderboardRegionId
@ -159,10 +160,10 @@ func getMarioKartWiiStoredGhostDataRecord(moduleName string, request StorageRequ
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{}, false
}
return database.SakeRecord{
return []database.SakeRecord{{
GameId: 1687,
TableId: "StoredGhostData",
RecordId: 0,
@ -171,7 +172,7 @@ func getMarioKartWiiStoredGhostDataRecord(moduleName string, request StorageRequ
"profile": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(pid)), 10)},
"fileid": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(fileId)), 10)},
},
}, true
}}, true
}
func handleMarioKartWiiFileDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) {
@ -335,7 +336,7 @@ func handleMarioKartWiiGhostUploadRequest(moduleName string, responseWriter http
return
}
// Mario Kart Wii expects player information to be in this form
playerInfo, _ = common.GameSpyBase64ToBase64(playerInfo, common.GameSpyBase64EncodingURLSafe)
playerInfo, _ = common.Base64Convert(playerInfo, base64.URLEncoding, base64.StdEncoding)
// The multipart boundary utilized by GameSpy does not conform to RFC 2045. To ensure compliance,
// we need to surround it with double quotation marks.
@ -405,7 +406,7 @@ func downloadedGhostFileHeader() []byte {
}
func isPlayerInfoValid(playerInfoString string) bool {
playerInfoByteArray, err := common.DecodeGameSpyBase64(playerInfoString, common.GameSpyBase64EncodingURLSafe)
playerInfoByteArray, err := base64.URLEncoding.DecodeString(playerInfoString)
if err != nil {
return false
}

View File

@ -4,7 +4,6 @@ import (
"encoding/xml"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"time"
@ -16,34 +15,6 @@ import (
"github.com/logrusorgru/aurora/v3"
)
const (
MaxSakeRecordsPerProfile = 96
MaxSakeFieldsPerRecord = 64
MaxSakeFieldValueLength = 4096
)
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"
@ -274,17 +245,25 @@ func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo,
}}
}
if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") {
table := GetTable(gameInfo.Name, request.TableID)
if table != nil && table.Reserved {
// Reserved for special handler
logging.Error(moduleName, "Attempt to create record in reserved table", aurora.Cyan(request.TableID))
logging.Error(moduleName, "Attempt to create record in reserved table", aurora.Cyan(request.TableID), "in game", aurora.BrightCyan(gameInfo.Name))
return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{
CreateRecordResult: ResultTableNotFound,
CreateRecordResult: ResultNoPermission,
}}
}
if !table.AllowsPublicCreate() {
logging.Error(moduleName, "Attempt to create record in table that doesn't allow public create", aurora.Cyan(request.TableID), "in game", aurora.BrightCyan(gameInfo.Name))
return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{
CreateRecordResult: ResultNoPermission,
}}
}
var record database.SakeRecord
var result string
record.Fields, result = getInputFields(moduleName, request)
record.Fields, result = getInputFields(moduleName, request, table, true)
if result != ResultSuccess {
return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{
CreateRecordResult: result,
@ -357,16 +336,25 @@ func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo,
}}
}
if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") {
table := GetTable(gameInfo.Name, request.TableID)
if table != nil && table.Reserved {
// Reserved for special handler
logging.Error(moduleName, "Attempt to update record in reserved table", aurora.Cyan(request.TableID), "in game", aurora.BrightCyan(gameInfo.Name))
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultTableNotFound,
UpdateRecordResult: ResultNoPermission,
}}
}
if !table.AllowsOwnerUpdate() {
logging.Error(moduleName, "Attempt to update record in table that doesn't allow owner update", aurora.Cyan(request.TableID), "in game", aurora.BrightCyan(gameInfo.Name))
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: ResultNoPermission,
}}
}
var record database.SakeRecord
var result string
record.Fields, result = getInputFields(moduleName, request)
record.Fields, result = getInputFields(moduleName, request, table, false)
if result != ResultSuccess {
return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{
UpdateRecordResult: result,
@ -403,46 +391,39 @@ func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo,
}}
}
var searchRecordHandlers = map[string]func(string, StorageRequestData) (database.SakeRecord, bool){
"mariokartwii/GhostData": getMarioKartWiiGhostDataRecord,
"mariokartwii/StoredGhostData": getMarioKartWiiStoredGhostDataRecord,
}
var (
standardFilterRegex = regexp.MustCompile(`^ownerid\s*=\s*-?(\d{1,10})$`)
)
// 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 request.TableID == "" {
logging.Error(moduleName, "No table ID provided")
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultTableNotFound,
}}
}
table := GetTable(gameInfo.Name, request.TableID)
if table != nil && table.SearchForRecordsHandler != nil {
var ok bool
records, ok = table.SearchForRecordsHandler(moduleName, request)
if !ok {
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultUnknownError,
}}
}
records = append(records, record)
} else if table != nil && table.Reserved {
logging.Error(moduleName, "Attempt to search for records in reserved table", aurora.Cyan(request.TableID))
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
SearchForRecordsResult: ResultTableNotFound,
}}
} else {
filter := request.Filter
ownerIds := request.OwnerIDs.OwnerID
// 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 = ""
if !table.AllowsPublicRead() {
ownerIds = []int32{int32(profileId)}
}
var err error
records, err = database.GetSakeRecords(pool, ctx, gameInfo.GameID, ownerIds, request.TableID, nil, request.Fields.Fields, filter)
records, err = database.GetSakeRecords(pool, ctx, gameInfo.GameID, ownerIds, request.TableID, nil, request.Fields.Fields, request.Filter)
if err != nil {
logging.Error(moduleName, "Failed to get sake records from the database:", err)
return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{
@ -451,7 +432,7 @@ func searchForRecords(moduleName string, profileId uint32, gameInfo common.GameI
}
}
// Sort the records now
// Sort the records now. TODO: This can be done more effectively in the database query.
sort.Slice(records, func(l, r int) bool {
lVal, lExists := records[l].Fields[request.Sort]
rVal, rExists := records[r].Fields[request.Sort]
@ -490,21 +471,20 @@ func searchForRecords(moduleName string, profileId uint32, gameInfo common.GameI
return StorageResponseBody{SearchForRecordsResponse: &response}
}
func getInputFields(moduleName string, request StorageRequestData) (map[string]database.SakeField, string) {
record := database.SakeRecord{
Fields: make(map[string]database.SakeField),
}
func getInputFields(moduleName string, request StorageRequestData, table *SakeTable, useDefault bool) (map[string]database.SakeField, string) {
if len(request.Values.RecordFields) > MaxSakeFieldsPerRecord {
logging.Error(moduleName, "Too many fields in record:", aurora.Cyan(len(request.Values.RecordFields)))
return nil, ResultFieldTypeInvalid
}
var fields map[string]database.SakeField
if useDefault {
fields = table.GetDefaultFields()
} else {
fields = make(map[string]database.SakeField)
}
for _, field := range request.Values.RecordFields {
value := field.Value.Value.Value
if len(value) > MaxSakeFieldValueLength {
logging.Error(moduleName, "Field value too long for field", aurora.Cyan(field.Name), "with length", aurora.Cyan(len(value)))
return nil, ResultFieldTypeInvalid
}
fieldType, ok := tagToSakeType[field.Value.Value.XMLName.Local]
if !ok {
logging.Error(moduleName, "Invalid field type tag:", aurora.Cyan(field.Value.Value.XMLName.Local))
@ -514,9 +494,17 @@ func getInputFields(moduleName string, request StorageRequestData) (map[string]d
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}
sakeField := database.SakeField{
Type: fieldType,
Value: value,
}
if result := table.CheckValidField(field.Name, sakeField); result != ResultSuccess {
logging.Error(moduleName, "Invalid value for field", aurora.Cyan(field.Name).String()+":", aurora.Cyan(value))
return nil, result
}
fields[field.Name] = sakeField
}
return record.Fields, ResultSuccess
return fields, ResultSuccess
}
func fillResponseValues(records []database.SakeRecord, request StorageRequestData) StorageResponseValues {

389
sake/tables.go Normal file
View File

@ -0,0 +1,389 @@
package sake
import (
"encoding/base64"
"strconv"
"time"
"wwfc/database"
)
const (
MaxSakeRecordsPerProfile = 96
MaxSakeFieldsPerRecord = 64
MaxSakeFieldValueLength = 4096
)
const DateAndTimeFormat = "2006-01-02T15:04:05.000"
const (
RateableUnknown = iota
RateableYes
RateableNo
)
type Rateable byte
const (
OwnerTypeProfile = iota
OwnerTypeBackend
)
type OwnerType byte
const (
PermissionDefault = iota
PermissionAllowed
PermissionDenied
)
type Permission byte
type SakeFieldDefinition struct {
// Type of the field.
Type database.SakeFieldType
// If empty, no default value will be set.
// "{EMPTY}" can be used to set an empty string default value.
// "{CURRENT_TIMESTAMP}" can be used to set the current timestamp as the default value (only for DateAndTime type).
Default string
// If zero, the default field length limit will be used.
LengthLimit int
// Optional function for custom validation.
IsValidFunc func(value string) bool
// Optional function for custom filtering. This function receives the value from the client AFTER validation, before inserting into the database.
FilterFromClientFunc func(value string) (string, error)
// Optional function for custom filtering. This function receives the value from the database before sending to the client.
FilterFromDatabaseFunc func(value string) (string, error)
}
type SakeTable struct {
// Determines whether the 'average_rating', 'my_rating', 'num_ratings', 'sum_ratings' fields are automatically added.
Rateable Rateable
// Defaults to profile-owned records if not specified.
OwnerType OwnerType
// Defaults to allowed if OwnerType is OwnerTypeProfile, denied otherwise.
PublicPermCreate Permission
// Defaults to allowed.
PublicPermRead Permission
// Defaults to allowed.
OwnerPermUpdate Permission
// Defaults to allowed.
OwnerPermDelete Permission
// Override the default maximum number of records per owner.
LimitPerOwner int
// If true, fields not specified in this table definition will be rejected.
Hardened bool
// If true, Sake will return a NoPermission error for requests that don't have a custom handler
Reserved bool
// Custom handler for SearchForRecords. Returns an array of response Sake records.
SearchForRecordsHandler func(string, StorageRequestData) ([]database.SakeRecord, bool)
// Field definitions for this table. The key is the field name.
Fields map[string]SakeFieldDefinition
}
var TableDefinitions = map[string]SakeTable{
"micchannelwii/userinfo": {
Rateable: RateableYes,
OwnerType: OwnerTypeProfile,
PublicPermCreate: PermissionAllowed,
PublicPermRead: PermissionAllowed,
OwnerPermUpdate: PermissionAllowed,
OwnerPermDelete: PermissionAllowed,
Fields: map[string]SakeFieldDefinition{
"wiiid": {
Type: database.SakeFieldTypeInt64,
},
"username": {
Type: database.SakeFieldTypeBinaryData,
},
"friendkey": {
Type: database.SakeFieldTypeInt64,
},
},
},
"mariokartwii/FriendInfo": {
Rateable: RateableNo,
OwnerType: OwnerTypeProfile,
PublicPermCreate: PermissionAllowed,
PublicPermRead: PermissionAllowed,
OwnerPermUpdate: PermissionAllowed,
OwnerPermDelete: PermissionAllowed,
LimitPerOwner: 1,
Hardened: false, // To allow modding extra fields
Fields: map[string]SakeFieldDefinition{
"info": {
Type: database.SakeFieldTypeBinaryData,
},
},
},
"mariokartwii/GhostData": {
Rateable: RateableNo,
OwnerType: OwnerTypeProfile,
PublicPermCreate: PermissionAllowed,
PublicPermRead: PermissionAllowed,
OwnerPermUpdate: PermissionAllowed,
OwnerPermDelete: PermissionAllowed,
Hardened: true,
Reserved: true,
SearchForRecordsHandler: getMarioKartWiiStoredGhostDataRecord,
Fields: map[string]SakeFieldDefinition{
"fileid": {
Type: database.SakeFieldTypeInt,
},
"profile": {
Type: database.SakeFieldTypeInt,
},
"course": {
Type: database.SakeFieldTypeInt,
},
"region": {
Type: database.SakeFieldTypeInt,
},
"time": {
Type: database.SakeFieldTypeInt,
},
},
},
"mariokartwii/StoredGhostData": {
Rateable: RateableNo,
OwnerType: OwnerTypeProfile,
PublicPermCreate: PermissionAllowed,
PublicPermRead: PermissionAllowed,
OwnerPermUpdate: PermissionAllowed,
OwnerPermDelete: PermissionAllowed,
Hardened: true,
Reserved: true,
SearchForRecordsHandler: getMarioKartWiiStoredGhostDataRecord,
Fields: map[string]SakeFieldDefinition{
"fileid": {
Type: database.SakeFieldTypeInt,
},
"profile": {
Type: database.SakeFieldTypeInt,
},
"course": {
Type: database.SakeFieldTypeInt,
},
"region": {
Type: database.SakeFieldTypeInt,
},
"time": {
Type: database.SakeFieldTypeInt,
},
},
},
"guinnesswrds/RecordTable": {
Rateable: RateableUnknown,
OwnerType: OwnerTypeProfile,
PublicPermCreate: PermissionAllowed,
PublicPermRead: PermissionAllowed,
OwnerPermUpdate: PermissionAllowed,
OwnerPermDelete: PermissionAllowed,
Fields: map[string]SakeFieldDefinition{
"Score": {
Type: database.SakeFieldTypeInt,
},
"GameID": {
Type: database.SakeFieldTypeByte,
},
"Region": {
Type: database.SakeFieldTypeByte,
},
"Country": {
Type: database.SakeFieldTypeByte,
},
"OwnerName": {
Type: database.SakeFieldTypeUnicodeString,
},
"AvatarName": {
Type: database.SakeFieldTypeUnicodeString,
},
"AvatarModel": {
Type: database.SakeFieldTypeByte,
},
"AvatarParts": {
Type: database.SakeFieldTypeBinaryData,
},
"DateTimeSet": {
Type: database.SakeFieldTypeDateAndTime,
Default: "{CURRENT_TIMESTAMP}",
},
},
},
}
func GetTable(gameName string, tableId string) *SakeTable {
tableDef, exists := TableDefinitions[gameName+"/"+tableId]
if !exists {
return nil
}
return &tableDef
}
func (t *SakeTable) AllowsPublicCreate() bool {
if t == nil {
return true
}
if t.PublicPermCreate == PermissionDefault {
return t.OwnerType == OwnerTypeProfile
}
return t.PublicPermCreate == PermissionAllowed
}
func (t *SakeTable) AllowsPublicRead() bool {
if t == nil {
return true
}
return t.PublicPermRead == PermissionAllowed || t.PublicPermRead == PermissionDefault
}
func (t *SakeTable) AllowsOwnerUpdate() bool {
if t == nil {
return true
}
return t.OwnerPermUpdate == PermissionAllowed || t.OwnerPermUpdate == PermissionDefault
}
func (t *SakeTable) AllowsOwnerDelete() bool {
if t == nil {
return true
}
return t.OwnerPermDelete == PermissionAllowed || t.OwnerPermDelete == PermissionDefault
}
func (t *SakeTable) GetDefaultFields() map[string]database.SakeField {
if t == nil {
return nil
}
defaultFields := make(map[string]database.SakeField)
for fieldName, fieldDef := range t.Fields {
if fieldDef.Default != "" {
value := fieldDef.Default
if value == "{CURRENT_TIMESTAMP}" && fieldDef.Type == database.SakeFieldTypeDateAndTime {
value = time.Now().UTC().Format(DateAndTimeFormat)
} else if value == "{EMPTY}" {
value = ""
}
defaultFields[fieldName] = database.SakeField{
Type: fieldDef.Type,
Value: value,
}
}
}
if t.Rateable == RateableYes {
defaultFields["average_rating"] = database.SakeField{
Type: database.SakeFieldTypeFloat,
Value: "0",
}
defaultFields["num_ratings"] = database.SakeField{
Type: database.SakeFieldTypeInt,
Value: "0",
}
}
return defaultFields
}
func (t *SakeTable) CheckValidField(fieldName string, field database.SakeField) string {
lengthLimit := MaxSakeFieldValueLength
var verifyFunc func(value string) bool
if t != nil && len(t.Fields) != 0 {
fieldDef, exists := t.Fields[fieldName]
if !exists {
if t.Hardened {
return ResultFieldNotFound
}
} else if fieldDef.Type != field.Type {
return ResultFieldTypeInvalid
}
if fieldDef.LengthLimit > 0 {
lengthLimit = fieldDef.LengthLimit
}
verifyFunc = fieldDef.IsValidFunc
}
if len(field.Value) > lengthLimit {
return ResultFieldTypeInvalid
}
// These values may not be written to
if fieldName == "average_rating" || fieldName == "my_rating" || fieldName == "num_ratings" || fieldName == "sum_ratings" {
return ResultFieldNotFound
}
switch field.Type {
case database.SakeFieldTypeByte:
if len(field.Value) == 0 || len(field.Value) > 3 {
return ResultFieldTypeInvalid
}
if parsed, err := strconv.ParseUint(field.Value, 10, 8); err != nil || strconv.FormatUint(parsed, 10) != field.Value {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeShort:
if len(field.Value) == 0 || len(field.Value) > 6 {
return ResultFieldTypeInvalid
}
if parsed, err := strconv.ParseInt(field.Value, 10, 16); err != nil || strconv.FormatInt(parsed, 10) != field.Value {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeInt:
if len(field.Value) == 0 || len(field.Value) > 11 {
return ResultFieldTypeInvalid
}
if parsed, err := strconv.ParseInt(field.Value, 10, 32); err != nil || strconv.FormatInt(parsed, 10) != field.Value {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeInt64:
if len(field.Value) == 0 || len(field.Value) > 20 {
return ResultFieldTypeInvalid
}
if parsed, err := strconv.ParseInt(field.Value, 10, 64); err != nil || strconv.FormatInt(parsed, 10) != field.Value {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeFloat:
if len(field.Value) == 0 || len(field.Value) > 24 {
return ResultFieldTypeInvalid
}
_, err := strconv.ParseFloat(field.Value, 32)
if err != nil {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeBoolean:
if field.Value != "true" && field.Value != "false" && field.Value != "1" && field.Value != "0" {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeDateAndTime:
if len(field.Value) == 0 || len(field.Value) > 24 {
return ResultFieldTypeInvalid
}
_, err := time.Parse(DateAndTimeFormat, field.Value)
if err != nil {
return ResultFieldTypeInvalid
}
case database.SakeFieldTypeBinaryData:
if len(field.Value) == 0 {
return ResultSuccess
}
binaryData, err := base64.StdEncoding.Strict().DecodeString(field.Value)
if err != nil {
return ResultFieldTypeInvalid
}
if len(binaryData) > lengthLimit {
return ResultFieldTypeInvalid
}
}
if verifyFunc != nil && !verifyFunc(field.Value) {
return ResultFieldTypeInvalid
}
return ResultSuccess
}