From ff8144f89f33683efa7ef7a8e5955574db3452af Mon Sep 17 00:00:00 2001 From: MikeIsAStar <99037623+MikeIsAStar@users.noreply.github.com> Date: Mon, 2 Sep 2024 11:30:00 -0400 Subject: [PATCH] SAKE: Support file uploads from Mario Kart Wii Helps address issue #43. --- common/encoding.go | 35 ++++++ common/gamyspy.go | 19 ++++ common/mario_kart_wii.go | 146 +++++++++--------------- database/mario_kart_wii.go | 62 ++++++++++ race/main.go | 25 ++++ race/nintendo_racing_service.go | 179 +++++++++++++++++++---------- sake/main.go | 9 +- sake/mario_kart_wii.go | 194 ++++++++++++++++++++++++++++++++ sake/storage.go | 25 +++- schema.sql | 18 +++ 10 files changed, 555 insertions(+), 157 deletions(-) create mode 100644 common/gamyspy.go create mode 100644 database/mario_kart_wii.go create mode 100644 sake/mario_kart_wii.go diff --git a/common/encoding.go b/common/encoding.go index 86c2c9b..aee84a7 100644 --- a/common/encoding.go +++ b/common/encoding.go @@ -2,6 +2,16 @@ package common import ( "encoding/base64" + "errors" + "strings" +) + +type GameSpyBase64Encoding int + +const ( + GameSpyBase64EncodingDefault = iota // 0 + GameSpyBase64EncodingAlternate // 1 + GameSpyBase64EncodingURLSafe // 2 ) var Base64DwcEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-").WithPadding('*') @@ -20,6 +30,31 @@ func Base32Encode(value uint64) string { return encoded } +func DecodeGameSpyBase64(gameSpyBase64 string, gameSpyBase64Encoding GameSpyBase64Encoding) ([]byte, error) { + base64String, err := GameSpyBase64ToBase64(gameSpyBase64, gameSpyBase64Encoding) + if err != nil { + return nil, 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") + } +} + func reverse(s string) string { rns := []rune(s) for i, j := 0, len(rns)-1; i < j; i, j = i+1, j-1 { diff --git a/common/gamyspy.go b/common/gamyspy.go new file mode 100644 index 0000000..d4ac5dc --- /dev/null +++ b/common/gamyspy.go @@ -0,0 +1,19 @@ +package common + +type SakeFileResult int + +const ( + GameSpyMultipartBoundary = "Qr4G823s23d---<<><><<<>--7d118e0536" + GameSpyGameIdMarioKartWii = 1687 +) + +// https://documentation.help/GameSpy-SDK/SAKEFileResult.html +const ( + SakeFileResultHeader = "Sake-File-Result" + + SakeFileResultSuccess = 0 + SakeFileResultMissingParameter = 3 + SakeFileResultFileNotFound = 4 + SakeFileResultFileTooLarge = 5 + SakeFileResultServerError = 6 +) diff --git a/common/mario_kart_wii.go b/common/mario_kart_wii.go index ad7f114..9d2dce7 100644 --- a/common/mario_kart_wii.go +++ b/common/mario_kart_wii.go @@ -1,110 +1,70 @@ package common -type MarioKartWiiRegionID int -type MarioKartWiiCourseID int - -const MarioKartWiiGameSpyGameID int = 1687 +type MarioKartWiiLeaderboardRegionId int +type MarioKartWiiCourseId int +type MarioKartWiiControllerId int const ( - Worldwide = iota // 0 - Japan = iota // 1 - UnitedStates = iota // 2 - Europe = iota // 3 - Australia = iota // 4 - Taiwan = iota // 5 - Korea = iota // 6 - China = iota // 7 + Worldwide = iota // 0x00 + Japan // 0x01 + UnitedStates // 0x02 + Europe // 0x03 + Australia // 0x04 + Taiwan // 0x05 + Korea // 0x06 + China // 0x07 ) const ( MarioCircuit = iota // 0x00 - MooMooMeadows = iota // 0x01 - MushroomGorge = iota // 0x02 - GrumbleVolcano = iota // 0x03 - ToadsFactory = iota // 0x04 - CoconutMall = iota // 0x05 - DKSummit = iota // 0x06 - WarioGoldMine = iota // 0x07 - LuigiCircuit = iota // 0x08 - DaisyCircuit = iota // 0x09 - MoonviewHighway = iota // 0x0A - MapleTreeway = iota // 0x0B - BowsersCastle = iota // 0x0C - RainbowRoad = iota // 0x0D - DryDryRuins = iota // 0x0E - KoopaCape = iota // 0x0F - GCNPeachBeach = iota // 0x10 - GCNMarioCircuit = iota // 0x11 - GCNWaluigiStadium = iota // 0x12 - GCNDKMountain = iota // 0x13 - DSYoshiFalls = iota // 0x14 - DSDesertHills = iota // 0x15 - DSPeachGardens = iota // 0x16 - DSDelfinoSquare = iota // 0x17 - SNESMarioCircuit3 = iota // 0x18 - SNESGhostValley2 = iota // 0x19 - N64MarioRaceway = iota // 0x1A - N64SherbetLand = iota // 0x1B - N64BowsersCastle = iota // 0x1C - N64DKsJungleParkway = iota // 0x1D - GBABowserCastle3 = iota // 0x1E - GBAShyGuyBeach = iota // 0x1F + MooMooMeadows // 0x01 + MushroomGorge // 0x02 + GrumbleVolcano // 0x03 + ToadsFactory // 0x04 + CoconutMall // 0x05 + DKSummit // 0x06 + WarioGoldMine // 0x07 + LuigiCircuit // 0x08 + DaisyCircuit // 0x09 + MoonviewHighway // 0x0A + MapleTreeway // 0x0B + BowsersCastle // 0x0C + RainbowRoad // 0x0D + DryDryRuins // 0x0E + KoopaCape // 0x0F + GCNPeachBeach // 0x10 + GCNMarioCircuit // 0x11 + GCNWaluigiStadium // 0x12 + GCNDKMountain // 0x13 + DSYoshiFalls // 0x14 + DSDesertHills // 0x15 + DSPeachGardens // 0x16 + DSDelfinoSquare // 0x17 + SNESMarioCircuit3 // 0x18 + SNESGhostValley2 // 0x19 + N64MarioRaceway // 0x1A + N64SherbetLand // 0x1B + N64BowsersCastle // 0x1C + N64DKsJungleParkway // 0x1D + GBABowserCastle3 // 0x1E + GBAShyGuyBeach // 0x1F ) -func (regionId MarioKartWiiRegionID) IsValid() bool { +const ( + WiiWheel = iota // 0x00 + WiiRemoteAndNunchuck // 0x01 + Classic // 0x02 + GameCube // 0x03 +) + +func (regionId MarioKartWiiLeaderboardRegionId) IsValid() bool { return regionId >= Worldwide && regionId <= China } -func (courseId MarioKartWiiCourseID) IsValid() bool { +func (courseId MarioKartWiiCourseId) IsValid() bool { return courseId >= MarioCircuit && courseId <= GBAShyGuyBeach } -func (regionId MarioKartWiiRegionID) ToString() string { - return [...]string{ - "Worldwide", - "Japan", - "United States", - "Europe", - "Australia", - "Taiwan", - "Korea", - "China", - }[regionId] -} - -func (courseId MarioKartWiiCourseID) ToString() string { - return [...]string{ - "Mario Circuit", - "Moo Moo Meadows", - "Mushroom Gorge", - "Grumble Volcano", - "Toad's Factory", - "Coconut Mall", - "DK Summit", - "Wario's Gold Mine", - "Luigi Circuit", - "Daisy Circuit", - "Moonview Highway", - "Maple Treeway", - "Bowser's Castle", - "Rainbow Road", - "Dry Dry Ruins", - "Koopa Cape", - "GCN Peach Beach", - "GCN Mario Circuit", - "GCN Waluigi Stadium", - "GCN DK Mountain", - "DS Yoshi Falls", - "DS Desert Hills", - "DS Peach Gardens", - "DS Delfino Square", - "SNES Mario Circuit 3", - "SNES Ghost Valley 2", - "N64 Mario Raceway", - "N64 Sherbet Land", - "N64 Bowser's Castle", - "N64 DK's Jungle Parkway", - "GBA Bowser Castle 3", - "GBA Shy Guy Beach", - }[courseId] +func (controllerId MarioKartWiiControllerId) IsValid() bool { + return controllerId >= WiiWheel && controllerId <= GameCube } diff --git a/database/mario_kart_wii.go b/database/mario_kart_wii.go new file mode 100644 index 0000000..a3358f2 --- /dev/null +++ b/database/mario_kart_wii.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "wwfc/common" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type MarioKartWiiTopTenRanking struct { + Score int + PID int + PlayerInfo string +} + +const ( + getTopTenRankingsQuery = "" + + "SELECT score, pid, playerinfo " + + "FROM mario_kart_wii_sake " + + "WHERE ($1 = 0 OR regionid = $1) " + + "AND courseid = $2 " + + "ORDER BY score ASC " + + "LIMIT 10" + uploadGhostFileStatement = "" + + "INSERT INTO mario_kart_wii_sake (regionid, courseid, score, pid, playerinfo, ghost) " + + "VALUES ($1, $2, $3, $4, $5, $6) " + + "ON CONFLICT (courseid, pid) DO UPDATE " + + "SET regionid = EXCLUDED.regionid, score = EXCLUDED.score, playerinfo = EXCLUDED.playerinfo, ghost = EXCLUDED.ghost" +) + +func GetMarioKartWiiTopTenRankings(pool *pgxpool.Pool, ctx context.Context, regionId common.MarioKartWiiLeaderboardRegionId, + courseId common.MarioKartWiiCourseId) ([]MarioKartWiiTopTenRanking, error) { + rows, err := pool.Query(ctx, getTopTenRankingsQuery, regionId, courseId) + if err != nil { + return nil, err + } + defer rows.Close() + + topTenRankings := make([]MarioKartWiiTopTenRanking, 0, 10) + for rows.Next() { + var topTenRanking MarioKartWiiTopTenRanking + err = rows.Scan(&topTenRanking.Score, &topTenRanking.PID, &topTenRanking.PlayerInfo) + if err != nil { + return nil, err + } + + topTenRankings = append(topTenRankings, topTenRanking) + } + err = rows.Err() + if err != nil { + return nil, err + } + + return topTenRankings, nil +} + +func UploadMarioKartWiiGhostFile(pool *pgxpool.Pool, ctx context.Context, regionId common.MarioKartWiiLeaderboardRegionId, + courseId common.MarioKartWiiCourseId, score int, pid int, playerInfo string, ghost []byte) error { + _, err := pool.Exec(ctx, uploadGhostFileStatement, regionId, courseId, score, pid, playerInfo, ghost) + + return err +} diff --git a/race/main.go b/race/main.go index b0c919d..3a060a3 100644 --- a/race/main.go +++ b/race/main.go @@ -1,14 +1,39 @@ package race import ( + "context" + "fmt" "net/http" "path" + "wwfc/common" "wwfc/logging" + "github.com/jackc/pgx/v4/pgxpool" "github.com/logrusorgru/aurora/v3" ) +var ( + ctx = context.Background() + pool *pgxpool.Pool +) + func StartServer(reload bool) { + // Get config + config := common.GetConfig() + + common.ReadGameList() + + // Start SQL + dbString := fmt.Sprintf("postgres://%s:%s@%s/%s", config.Username, config.Password, config.DatabaseAddress, config.DatabaseName) + dbConf, err := pgxpool.ParseConfig(dbString) + if err != nil { + panic(err) + } + + pool, err = pgxpool.ConnectConfig(ctx, dbConf) + if err != nil { + panic(err) + } } func Shutdown() { diff --git a/race/nintendo_racing_service.go b/race/nintendo_racing_service.go index d8e7002..e65e05a 100644 --- a/race/nintendo_racing_service.go +++ b/race/nintendo_racing_service.go @@ -5,7 +5,9 @@ import ( "io" "net/http" "strconv" + "strings" "wwfc/common" + "wwfc/database" "wwfc/logging" "github.com/logrusorgru/aurora/v3" @@ -21,17 +23,17 @@ type rankingsRequestBody struct { type rankingsRequestData struct { XMLName xml.Name - GameId int `xml:"gameid"` - RegionId common.MarioKartWiiRegionID `xml:"regionid"` - CourseId common.MarioKartWiiCourseID `xml:"courseid"` + GameId int `xml:"gameid"` + RegionId common.MarioKartWiiLeaderboardRegionId `xml:"regionid"` + CourseId common.MarioKartWiiCourseId `xml:"courseid"` } type rankingsResponseRankingDataResponse struct { - XMLName xml.Name `xml:"RankingDataResponse"` - XMLNSXsi string `xml:"xmlns:xsi,attr"` - XMLNSXsd string `xml:"xmlns:xsd,attr"` - XMLNS string `xml:"xmlns,attr"` - ResponseCode int `xml:"responseCode"` + XMLName xml.Name `xml:"RankingDataResponse"` + XMLNSXSI string `xml:"xmlns:xsi,attr"` + XMLNSXSD string `xml:"xmlns:xsd,attr"` + XMLNS string `xml:"xmlns,attr"` + ResponseCode raceServiceResult `xml:"responseCode"` DataArray rankingsResponseDataArray } @@ -54,10 +56,40 @@ type rankingsResponseRankingData struct { UserData string `xml:"userdata"` } +type raceServiceResult int + +// https://github.com/GameProgressive/UniSpySDK/blob/master/webservices/RacingService.h +const ( + raceServiceResultSuccess = 0 + raceServiceResultDatabaseError = 6 + raceServiceResultParseError = 101 + raceServiceResultInvalidParameters = 105 +) + +const ( + xmlNamespaceXSI = "http://www.w3.org/2001/XMLSchema-instance" + xmlNamespaceXSD = "http://www.w3.org/2001/XMLSchema" + xmlNamespace = "http://gamespy.net/RaceService/" +) + func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { soapActionHeader := request.Header.Get("SOAPAction") if soapActionHeader == "" { logging.Error(moduleName, "No SOAPAction header") + writeErrorResponse(raceServiceResultParseError, responseWriter) + return + } + + slashIndex := strings.LastIndex(soapActionHeader, "/") + if slashIndex == -1 { + logging.Error(moduleName, "Invalid SOAPAction header") + writeErrorResponse(raceServiceResultParseError, responseWriter) + return + } + quotationMarkIndex := strings.Index(soapActionHeader[slashIndex+1:], "\"") + if quotationMarkIndex == -1 { + logging.Error(moduleName, "Invalid SOAPAction header") + writeErrorResponse(raceServiceResultParseError, responseWriter) return } @@ -66,78 +98,103 @@ func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.R panic(err) } - requestXML := rankingsRequestEnvelope{} - err = xml.Unmarshal(requestBody, &requestXML) - if err != nil { - logging.Error(moduleName, "Got malformed XML") - return - } - requestData := requestXML.Body.Data - - gameId := requestData.GameId - if gameId != common.MarioKartWiiGameSpyGameID { - logging.Error(moduleName, "Wrong GameSpy game id") - return - } - - soapAction := requestData.XMLName.Local + soapAction := soapActionHeader[slashIndex+1 : slashIndex+1+quotationMarkIndex] switch soapAction { case "GetTopTenRankings": - regionId := requestData.RegionId - courseId := requestData.CourseId - - if !regionId.IsValid() { - logging.Error(moduleName, "Invalid region id") - return - } - if courseId < common.MarioCircuit { - logging.Error(moduleName, "Invalid course id") - return - } - - var topTenLeaderboard string - if courseId <= common.GBAShyGuyBeach { - topTenLeaderboard = courseId.ToString() - } else { - topTenLeaderboard = "a competition" - } - - logging.Info(moduleName, "Received a request for the Top 10 of", aurora.BrightCyan(topTenLeaderboard)) - handleGetTopTenRankingsRequest(moduleName, responseWriter) + logging.Info(moduleName, "Received a Top 10 rankings request") + handleGetTopTenRankingsRequest(moduleName, responseWriter, requestBody) + default: + logging.Info(moduleName, "Unhandled SOAPAction:", aurora.Cyan(soapAction)) } } -func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.ResponseWriter) { - rankingData := rankingsResponseRankingData{ - OwnerID: 1000000404, - Rank: 1, - Time: 0, - UserData: "xC0AIABNAGkAawBlAFMAdABhAHIAIAAAhH/RTQAAAAAgB45hkAAQTEDyjqQAeLgPhq4AiiUEACAATQBpAGsAZQBTAHQAYQByACD0UwACAAE=", +func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.ResponseWriter, requestBody []byte) { + requestXML := rankingsRequestEnvelope{} + err := xml.Unmarshal(requestBody, &requestXML) + if err != nil { + logging.Error(moduleName, "Got malformed XML") + writeErrorResponse(raceServiceResultParseError, responseWriter) + return } - responseData := []rankingsResponseData{ - { + requestData := requestXML.Body.Data + + gameId := requestData.GameId + if gameId != common.GameSpyGameIdMarioKartWii { + logging.Error(moduleName, "Wrong GameSpy game id") + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + regionId := requestData.RegionId + courseId := requestData.CourseId + + if !regionId.IsValid() { + logging.Error(moduleName, "Invalid region id") + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + if courseId < common.MarioCircuit || courseId > 32767 { + logging.Error(moduleName, "Invalid course id") + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + topTenRankings, err := database.GetMarioKartWiiTopTenRankings(pool, ctx, regionId, courseId) + if err != nil { + logging.Error(moduleName, "Failed to get the Top 10 rankings") + writeErrorResponse(raceServiceResultDatabaseError, responseWriter) + return + } + + numberOfRankings := len(topTenRankings) + data := make([]rankingsResponseData, 0, numberOfRankings) + for i, topTenRanking := range topTenRankings { + rankingData := rankingsResponseRankingData{ + OwnerID: topTenRanking.PID, + Rank: i + 1, + Time: topTenRanking.Score, + UserData: topTenRanking.PlayerInfo, + } + + responseData := rankingsResponseData{ RankingData: rankingData, - }, + } + + data = append(data, responseData) } dataArray := rankingsResponseDataArray{ - NumRecords: len(responseData), - Data: responseData, + NumRecords: numberOfRankings, + Data: data, } rankingDataResponse := rankingsResponseRankingDataResponse{ - XMLNSXsi: "http://www.w3.org/2001/XMLSchema-instance", - XMLNSXsd: "http://www.w3.org/2001/XMLSchema", - XMLNS: "http://gamespy.net/RaceService/", - ResponseCode: 0, + XMLNSXSI: xmlNamespaceXSI, + XMLNSXSD: xmlNamespaceXSD, + XMLNS: xmlNamespace, + ResponseCode: raceServiceResultSuccess, DataArray: dataArray, } + writeResponse(responseWriter, rankingDataResponse) +} + +func writeErrorResponse(raceServiceResult raceServiceResult, responseWriter http.ResponseWriter) { + rankingDataResponse := rankingsResponseRankingDataResponse{ + XMLNSXSI: xmlNamespaceXSI, + XMLNSXSD: xmlNamespaceXSD, + XMLNS: xmlNamespace, + ResponseCode: raceServiceResult, + } + + writeResponse(responseWriter, rankingDataResponse) +} + +func writeResponse(responseWriter http.ResponseWriter, rankingDataResponse rankingsResponseRankingDataResponse) { responseBody, err := xml.Marshal(rankingDataResponse) if err != nil { - logging.Error(moduleName, "Failed to XML encode the data") - return + panic(err) } responseBody = append([]byte(xml.Header), responseBody...) diff --git a/sake/main.go b/sake/main.go index d5c1888..d6b9ff1 100644 --- a/sake/main.go +++ b/sake/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "wwfc/common" "wwfc/logging" @@ -41,9 +42,13 @@ func Shutdown() { func HandleRequest(w http.ResponseWriter, r *http.Request) { logging.Info("SAKE", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) - switch r.URL.String() { - case "/SakeStorageServer/StorageServer.asmx": + urlPath := r.URL.Path + switch { + case urlPath == "/SakeStorageServer/StorageServer.asmx": moduleName := "SAKE:Storage:" + r.RemoteAddr handleStorageRequest(moduleName, w, r) + case strings.Contains(urlPath, "upload.aspx"): + moduleName := "SAKE:File:" + r.RemoteAddr + handleFileUploadRequest(moduleName, w, r) } } diff --git a/sake/mario_kart_wii.go b/sake/mario_kart_wii.go new file mode 100644 index 0000000..ff61ba4 --- /dev/null +++ b/sake/mario_kart_wii.go @@ -0,0 +1,194 @@ +package sake + +import ( + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "net/http" + "strconv" + "strings" + "wwfc/common" + "wwfc/database" + "wwfc/logging" +) + +type playerInfo struct { + MiiData [0x4C]byte // 0x00 + ControllerId byte // 0x4C + Unknown byte // 0x4D + StateCode byte // 0x4E + CountryCode byte // 0x4F +} + +const ( + playerInfoSize = 0x50 + + rkgdFileMaxSize = 0x2800 + rkgdFileMinSize = 0x0088 + 0x0008 + 0x0004 + rkgdFileName = "ghost.bin" +) + +func handleMarioKartWiiFileUploadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + + regionIdString := query.Get("regionid") + courseIdString := query.Get("courseid") + scoreString := query.Get("score") + pidString := query.Get("pid") + playerInfo := query.Get("playerinfo") + + regionIdInt, err := strconv.Atoi(regionIdString) + if err != nil { + logging.Error(moduleName, "Invalid region id") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + regionId := common.MarioKartWiiLeaderboardRegionId(regionIdInt) + if !regionId.IsValid() || regionId == common.Worldwide { + logging.Error(moduleName, "Invalid region id") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + + courseIdInt, err := strconv.Atoi(courseIdString) + if err != nil { + logging.Error(moduleName, "Invalid course id") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + courseId := common.MarioKartWiiCourseId(courseIdInt) + if courseId < common.MarioCircuit || courseId > 32767 { + logging.Error(moduleName, "Invalid course id") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + + score, err := strconv.Atoi(scoreString) + if err != nil || score <= 0 { + logging.Error(moduleName, "Invalid score") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + + pid, err := strconv.Atoi(pidString) + if err != nil || pid <= 0 { + logging.Error(moduleName, "Invalid pid") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + + if !isPlayerInfoValid(playerInfo) { + logging.Error(moduleName, "Invalid player info") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultMissingParameter)) + return + } + // Mario Kart Wii expects player information to be in this form + playerInfo, _ = common.GameSpyBase64ToBase64(playerInfo, common.GameSpyBase64EncodingURLSafe) + + // The multipart boundary utilized by GameSpy does not conform to RFC 2045. To ensure compliance, + // we need to surround it with double quotation marks. + contentType := request.Header.Get("Content-Type") + boundary := getMultipartBoundary(contentType) + if boundary == common.GameSpyMultipartBoundary { + quotedBoundary := fmt.Sprintf("%q", boundary) + contentType := strings.Replace(contentType, boundary, quotedBoundary, 1) + request.Header.Set("Content-Type", contentType) + } + + err = request.ParseMultipartForm(rkgdFileMaxSize) + if err != nil { + logging.Error(moduleName, "Failed to parse the multipart form") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultFileNotFound)) + return + } + + file, fileHeader, err := request.FormFile(rkgdFileName) + if err != nil { + logging.Error(moduleName, "Failed to find the ghost file") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultFileNotFound)) + return + } + defer file.Close() + + if fileHeader.Size < rkgdFileMinSize || fileHeader.Size > rkgdFileMaxSize { + logging.Error(moduleName, "The size of the ghost file is invalid") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultFileTooLarge)) + return + } + + ghostFile := make([]byte, fileHeader.Size) + _, err = io.ReadFull(file, ghostFile) + if err != nil { + logging.Error(moduleName, "Failed to read contents of the ghost file") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultFileTooLarge)) + return + } + + if !isRKGDFileValid(ghostFile) { + logging.Error(moduleName, "Received an invalid ghost file") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultFileTooLarge)) + return + } + + err = database.UploadMarioKartWiiGhostFile(pool, ctx, regionId, courseId, score, pid, playerInfo, ghostFile) + if err != nil { + logging.Error(moduleName, "Failed to insert the ghost file into the database") + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultServerError)) + return + } + + responseWriter.Header().Set(common.SakeFileResultHeader, strconv.Itoa(common.SakeFileResultSuccess)) +} + +func isPlayerInfoValid(playerInfoString string) bool { + playerInfoByteArray, err := common.DecodeGameSpyBase64(playerInfoString, common.GameSpyBase64EncodingURLSafe) + if err != nil { + return false + } + + if len(playerInfoByteArray) != playerInfoSize { + return false + } + + var playerInfo playerInfo + reader := bytes.NewReader(playerInfoByteArray) + err = binary.Read(reader, binary.BigEndian, &playerInfo) + if err != nil { + return false + } + + if common.RFLCalculateCRC(playerInfo.MiiData[:]) != 0x0000 { + return false + } + + controllerId := common.MarioKartWiiControllerId(playerInfo.ControllerId) + + return controllerId.IsValid() +} + +func getMultipartBoundary(contentType string) string { + startIndex := strings.Index(contentType, "boundary=") + if startIndex == -1 { + return "" + } + startIndex += len("boundary=") + + return contentType[startIndex:] +} + +func isRKGDFileValid(rkgdFile []byte) bool { + rkgdFileMagic := []byte{'R', 'K', 'G', 'D'} + + if !bytes.Equal(rkgdFile[:4], rkgdFileMagic) { + return false + } + + rkgdFileLength := len(rkgdFile) + + expectedChecksum := binary.BigEndian.Uint32(rkgdFile[rkgdFileLength-4:]) + checksum := crc32.ChecksumIEEE(rkgdFile[:rkgdFileLength-4]) + + return checksum == expectedChecksum +} diff --git a/sake/storage.go b/sake/storage.go index 70a1f90..664e3c9 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -112,6 +112,10 @@ type StorageSearchForRecordsResponse struct { Values StorageResponseValues `xml:"values"` // ??? } +var fileUploadHandlers = map[int]func(string, http.ResponseWriter, *http.Request){ + common.GameSpyGameIdMarioKartWii: handleMarioKartWiiFileUploadRequest, +} + func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Request) { headerAction := r.Header.Get("SOAPAction") if headerAction == "" { @@ -167,13 +171,32 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ panic(err) } - payload := append([]byte(``), 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 handleFileUploadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + + gameIdString := query.Get("gameid") + gameId, err := strconv.Atoi(gameIdString) + if err != nil { + logging.Error(moduleName, "Invalid GameSpy game id") + return + } + + handler, handlerExists := fileUploadHandlers[gameId] + if !handlerExists { + logging.Warn(moduleName, "Unhandled file upload request for game id:", aurora.Cyan(gameId)) + return + } + + handler(moduleName, responseWriter, request) +} + func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, common.GameInfo, bool) { gameInfo := common.GetGameInfoByID(request.GameID) if gameInfo == nil { diff --git a/schema.sql b/schema.sql index 5d99065..7364939 100644 --- a/schema.sql +++ b/schema.sql @@ -52,6 +52,24 @@ ALTER TABLE ONLY public.users ALTER TABLE public.users OWNER TO wiilink; +-- +-- Name: mario_kart_wii_sake; Type: TABLE; Schema: public; Owner: wiilink +-- + +CREATE TABLE IF NOT EXISTS public.mario_kart_wii_sake ( + regionid smallint NOT NULL CHECK (regionid >= 1 AND regionid <= 7), + courseid smallint NOT NULL CHECK (courseid >= 0 AND courseid <= 32767), + score integer NOT NULL CHECK (score > 0), + pid integer NOT NULL CHECK (pid > 0), + playerinfo varchar(108) NOT NULL CHECK (LENGTH(playerinfo) = 108), + ghost bytea CHECK (ghost IS NULL OR (OCTET_LENGTH(ghost) BETWEEN 148 AND 10240)), + + CONSTRAINT one_time_per_course_constraint UNIQUE (courseid, pid) +); + + +ALTER TABLE public.mario_kart_wii_sake OWNER TO wiilink; + -- -- Name: users_profile_id_seq; Type: SEQUENCE; Schema: public; Owner: wiilink --