package sake import ( "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 ( 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" ) type StorageRequestEnvelope struct { XMLName xml.Name Body StorageRequestBody } type StorageRequestBody struct { XMLName xml.Name Data StorageRequestData `xml:",any"` } type StorageRequestData struct { XMLName xml.Name GameID int `xml:"gameid"` SecretKey string `xml:"secretKey"` LoginTicket string `xml:"loginTicket"` TableID string `xml:"tableid"` 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 StorageOwnerIDs `xml:"ownerids"` CacheFlag int `xml:"cacheFlag"` Fields StorageFields `xml:"fields"` Values StorageUpdateRecordValues `xml:"values"` } type StorageFields struct { XMLName xml.Name Fields []string `xml:"string"` } type StorageUpdateRecordValues struct { RecordFields []StorageRecordField `xml:"RecordField"` } type StorageRecordField struct { Name string `xml:"name"` Value StorageRecordValue `xml:"value"` } type StorageRecordValue struct { XMLName xml.Name Value *StorageValue `xml:",any"` } type StorageValue struct { XMLName xml.Name Value string `xml:"value"` } type StorageOwnerIDs struct { OwnerID []int32 `xml:"int"` } type StorageResponseEnvelope struct { XMLName xml.Name Body StorageResponseBody `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"` } type StorageResponseBody struct { CreateRecordResponse *StorageCreateRecordResponse `xml:"http://gamespy.net/sake CreateRecordResponse"` UpdateRecordResponse *StorageUpdateRecordResponse `xml:"http://gamespy.net/sake UpdateRecordResponse"` GetMyRecordsResponse *StorageGetMyRecordsResponse `xml:"http://gamespy.net/sake GetMyRecordsResponse"` SearchForRecordsResponse *StorageSearchForRecordsResponse `xml:"http://gamespy.net/sake SearchForRecordsResponse"` } type StorageResponseValues struct { ArrayOfRecordValue []StorageArrayOfRecordValue `xml:"ArrayOfRecordValue"` } type StorageArrayOfRecordValue struct { RecordValues []StorageRecordValue `xml:"RecordValue"` } type StorageCreateRecordResponse struct { CreateRecordResult string RecordID int32 `xml:"recordid"` } type StorageUpdateRecordResponse struct { UpdateRecordResult string } type StorageGetMyRecordsResponse struct { GetMyRecordsResult string Values StorageResponseValues `xml:"values"` } type StorageSearchForRecordsResponse struct { XMLName xml.Name SearchForRecordsResult string 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 == "" { logging.Error(moduleName, "No SOAPAction in header") return } body, err := io.ReadAll(r.Body) if err != nil { 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) if err != nil { logging.Error(moduleName, "Received invalid XML") return } response := StorageResponseEnvelope{ XMLName: xml.Name{Space: SOAPEnvNamespace, Local: "Envelope"}, } 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)) handler, ok := storageRequestHandlers[xmlName] if !ok { panic("unknown SOAPAction: " + aurora.Cyan(xmlName).String()) } 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)) } out, err := xml.Marshal(response) if err != nil { panic(err) } logging.Info(moduleName, "Responding with body:", aurora.Cyan(string(out))) payload := append([]byte(xml.Header), out...) w.Header().Set("Content-Type", "text/xml") w.Header().Set("Content-Length", strconv.Itoa(len(payload))) w.Write(payload) } 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{}, 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{}, ResultSecretKeyInvalid } profileId, issueTime, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket) if err != nil { logging.Error(moduleName, err) return 0, common.GameInfo{}, ResultLoginTicketInvalid } if issueTime.Add(48 * time.Hour).Before(time.Now()) { return 0, common.GameInfo{}, ResultLoginTicketExpired } return profileId, *gameInfo, ResultSuccess } func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody { if reached, err := database.IsMaxSakeRecordsReached(pool, ctx, profileId, MaxSakeRecordsPerProfile); err != nil { logging.Error(moduleName, "Failed to check max sake records:", err) return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultDatabaseUnavailable, }} } else if reached { logging.Error(moduleName, "Profile", aurora.Cyan(profileId), "has reached the maximum number of sake records") return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultRecordLimitReached, }} } if request.TableID == "" { logging.Error(moduleName, "No table ID provided") return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultTableNotFound, }} } if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") { // Reserved for special handler return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultTableNotFound, }} } var record database.SakeRecord var result string record.Fields, result = getInputFields(moduleName, request) if result != ResultSuccess { return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: 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 recordId, err := database.InsertSakeRecord(pool, ctx, record) if err != nil { return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultDatabaseUnavailable, }} } logging.Info(moduleName, "Created record in table", aurora.Cyan(record.TableId), "with ID", aurora.Cyan(recordId), "for profile", aurora.Cyan(profileId)) return StorageResponseBody{CreateRecordResponse: &StorageCreateRecordResponse{ CreateRecordResult: ResultSuccess, RecordID: recordId, }} } 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: 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)) return StorageResponseBody{GetMyRecordsResponse: &response} } 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, }} } 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, }} } if err == pgx.ErrNoRows { return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ UpdateRecordResult: ResultRecordNotFound, }} } return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ UpdateRecordResult: ResultDatabaseUnavailable, }} } 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, }} } 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 !ok { return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{ SearchForRecordsResult: ResultUnknownError, }} } records = append(records, record) } 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 = "" } 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, "Failed to get sake records from the database:", err) return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{ SearchForRecordsResult: ResultDatabaseUnavailable, }} } } // 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.Type != database.SakeFieldTypeInt || rVal.Type != database.SakeFieldTypeInt { panic(aurora.Cyan(lVal.Type).String() + " used as sort value") } lValInt, err := strconv.ParseInt(lVal.Value, 10, 64) if err != nil { panic(err) } rValInt, err := strconv.ParseInt(rVal.Value, 10, 64) if err != nil { panic(err) } 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, "Found", aurora.Cyan(len(records)), "records in table", aurora.Cyan(request.TableID), "for profile", aurora.Cyan(profileId), "with filter", aurora.Cyan(request.Filter)) return StorageResponseBody{SearchForRecordsResponse: &response} } func getInputFields(moduleName string, request StorageRequestData) (map[string]database.SakeField, string) { record := database.SakeRecord{ Fields: make(map[string]database.SakeField), } if len(request.Values.RecordFields) > MaxSakeFieldsPerRecord { logging.Error(moduleName, "Too many fields in record:", aurora.Cyan(len(request.Values.RecordFields))) return nil, ResultFieldTypeInvalid } 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)) return nil, ResultFieldTypeInvalid } if field.Name == "ownerid" || field.Name == "recordid" || field.Name == "gameid" || field.Name == "tableid" { 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 fillResponseValues(records []database.SakeRecord, request StorageRequestData) StorageResponseValues { var response StorageResponseValues for _, record := range records { valueArray := StorageArrayOfRecordValue{} for _, field := range request.Fields.Fields { 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 + "/CreateRecord": body.CreateRecordResponse = &StorageCreateRecordResponse{ CreateRecordResult: result, } case SakeNamespace + "/UpdateRecord": body.UpdateRecordResponse = &StorageUpdateRecordResponse{ UpdateRecordResult: result, } case SakeNamespace + "/GetMyRecords": body.GetMyRecordsResponse = &StorageGetMyRecordsResponse{ GetMyRecordsResult: result, } case SakeNamespace + "/SearchForRecords": body.SearchForRecordsResponse = &StorageSearchForRecordsResponse{} } }