From 865d8a8df262c6736012239bdbda695deba9f20c Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:22:15 -0400 Subject: [PATCH] SAKE: Create game-specific table definitions Additionally adds verification and sanity checks for all values passed to Sake from the client --- common/encoding.go | 37 +--- sake/constants.go | 22 +++ sake/mario_kart_wii.go | 55 +++--- sake/storage.go | 136 +++++++------- sake/tables.go | 389 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 131 deletions(-) create mode 100644 sake/tables.go diff --git a/common/encoding.go b/common/encoding.go index aee84a7..f4b8a7d 100644 --- a/common/encoding.go +++ b/common/encoding.go @@ -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 { diff --git a/sake/constants.go b/sake/constants.go index 9a47fea..68e833f 100644 --- a/sake/constants.go +++ b/sake/constants.go @@ -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 +) diff --git a/sake/mario_kart_wii.go b/sake/mario_kart_wii.go index 1f5b8c3..88f9c07 100644 --- a/sake/mario_kart_wii.go +++ b/sake/mario_kart_wii.go @@ -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 } diff --git a/sake/storage.go b/sake/storage.go index 35379df..3ce373e 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -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 { diff --git a/sake/tables.go b/sake/tables.go new file mode 100644 index 0000000..907ae35 --- /dev/null +++ b/sake/tables.go @@ -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 +}