diff --git a/common/gamespy_message.go b/common/gamespy_message.go index b8d3d8d..1b5cf46 100644 --- a/common/gamespy_message.go +++ b/common/gamespy_message.go @@ -3,6 +3,7 @@ package common import ( "errors" "fmt" + "strconv" "strings" ) @@ -14,9 +15,10 @@ type GameSpyCommand struct { var ( ErrInvalidGameSpyCommand = errors.New("invalid GameSpy command received") + ErrNoGameStatsDataLength = errors.New("no data length found in GameStats message") ) -func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { +func parseGameSpyMessage(msg string, gameStats bool) ([]GameSpyCommand, error) { if !strings.Contains(msg, `\final\`) { return nil, ErrInvalidGameSpyCommand } @@ -43,7 +45,26 @@ func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { break } - if strings.Contains(msg, `\`) { + if gameStats && key == "data" { + if g.OtherValues["length"] == "" { + return nil, ErrNoGameStatsDataLength + } + + dataLength, err := strconv.Atoi(g.OtherValues["length"]) + if err != nil { + return nil, err + } + + if len(msg) < dataLength+1 { + return nil, ErrInvalidGameSpyCommand + } + + value = msg[:dataLength] + msg = msg[dataLength:] + if msg[0] == '\\' { + msg = msg[1:] + } + } else if strings.Contains(msg, `\`) { if msg[0] != '\\' { valueEnd := strings.Index(msg[1:], `\`) value = msg[:valueEnd+1] @@ -70,12 +91,27 @@ func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { return commands, nil } +func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { + return parseGameSpyMessage(msg, false) +} + +func ParseGameStatsMessage(msg string) ([]GameSpyCommand, error) { + return parseGameSpyMessage(msg, true) +} + func CreateGameSpyMessage(command GameSpyCommand) string { query := "" + endQuery := "" for k, v := range command.OtherValues { - query += fmt.Sprintf(`\%s\%s`, k, v) + if command.Command == "getpdr" && k == "data" { + endQuery += fmt.Sprintf(`\%s\%s`, k, v) + } else { + query += fmt.Sprintf(`\%s\%s`, k, v) + } } + query += endQuery + if command.Command != "" { query = fmt.Sprintf(`\%s\%s%s`, command.Command, command.CommandValue, query) } diff --git a/database/gamestats.go b/database/gamestats.go new file mode 100644 index 0000000..c2c778e --- /dev/null +++ b/database/gamestats.go @@ -0,0 +1,29 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4/pgxpool" +) + +const ( + queryGsGetPublicData = `SELECT modified_time, pdata FROM gamestats_public_data WHERE profile_id = $1 AND dindex = $2 AND ptype = $3` + queryGsInsertPublicData = `INSERT INTO gamestats_public_data (profile_id, dindex, ptype, pdata, modified_time) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) RETURNING modified_time` + queryGsUpdatePublicData = `UPDATE gamestats_public_data SET pdata = $4, modified_time = CURRENT_TIMESTAMP WHERE profile_id = $1 AND dindex = $2 AND ptype = $3 RETURNING modified_time` +) + +func GetGameStatsPublicData(pool *pgxpool.Pool, ctx context.Context, profileId uint32, dindex string, ptype string) (modifiedTime time.Time, publicData string, err error) { + err = pool.QueryRow(ctx, queryGsGetPublicData, profileId, dindex, ptype).Scan(&modifiedTime, &publicData) + return +} + +func CreateGameStatsPublicData(pool *pgxpool.Pool, ctx context.Context, profileId uint32, dindex string, ptype string, publicData string) (modifiedTime time.Time, err error) { + err = pool.QueryRow(ctx, queryGsInsertPublicData, profileId, dindex, ptype, publicData).Scan(&modifiedTime) + return +} + +func UpdateGameStatsPublicData(pool *pgxpool.Pool, ctx context.Context, profileId uint32, dindex string, ptype string, publicData string) (modifiedTime time.Time, err error) { + err = pool.QueryRow(ctx, queryGsUpdatePublicData, profileId, dindex, ptype, publicData).Scan(&modifiedTime) + return +} diff --git a/gamestats/getpd.go b/gamestats/getpd.go index 1c9a7b5..ba37e55 100644 --- a/gamestats/getpd.go +++ b/gamestats/getpd.go @@ -2,13 +2,70 @@ package gamestats import ( "strconv" - "time" "wwfc/common" + "wwfc/database" + "wwfc/logging" + + "github.com/jackc/pgx/v4" + "github.com/logrusorgru/aurora/v3" ) func (g *GameStatsSession) getpd(command common.GameSpyCommand) { - // Temporary empty data, it's an embedded gamespy \key\value message excluding \final\ - data := `\\` + errMsg := common.GameSpyCommand{ + Command: "getpdr", + CommandValue: "0", + OtherValues: map[string]string{ + "pid": command.OtherValues["pid"], + "lid": strconv.Itoa(g.LoginID), + }, + } + + profileIdStr, ok := command.OtherValues["pid"] + if !ok { + logging.Error(g.ModuleName, "Missing pid") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + profileId, err := strconv.ParseUint(profileIdStr, 10, 32) + if err != nil { + logging.Error(g.ModuleName, "Invalid pid:", aurora.Cyan(profileIdStr)) + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + dindex, ok := command.OtherValues["dindex"] + if !ok { + logging.Error(g.ModuleName, "Missing dindex") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + ptype, ok := command.OtherValues["ptype"] + if !ok { + logging.Error(g.ModuleName, "Missing ptype") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + logging.Info(g.ModuleName, "Get public data: PID:", aurora.Cyan(profileId), "Index:", aurora.Cyan(dindex), "Type:", aurora.Cyan(ptype)) + + modifiedTime, data, err := database.GetGameStatsPublicData(pool, ctx, uint32(profileId), dindex, ptype) + if err != nil { + if err != pgx.ErrNoRows { + logging.Error(g.ModuleName, "GetGameStatsPublicData returned", err) + g.Write(errMsg) + return + } + + logging.Warn(g.ModuleName, "No data found") + g.Write(errMsg) + return + } g.Write(common.GameSpyCommand{ Command: "getpdr", @@ -16,9 +73,9 @@ func (g *GameStatsSession) getpd(command common.GameSpyCommand) { OtherValues: map[string]string{ "lid": strconv.Itoa(g.LoginID), "pid": command.OtherValues["pid"], - "mod": strconv.Itoa(int(time.Now().Unix())), - "length": strconv.Itoa(len(data) + 1), - "data": `\` + data + `\`, + "mod": strconv.Itoa(int(modifiedTime.Unix())), + "length": strconv.Itoa(len(data)), + "data": data, }, }) } diff --git a/gamestats/main.go b/gamestats/main.go index db5d805..59c5942 100644 --- a/gamestats/main.go +++ b/gamestats/main.go @@ -222,7 +222,7 @@ func HandlePacket(index uint64, data []byte) { message := decrypted.String() session.ReadBuffer = []byte{} - commands, err := common.ParseGameSpyMessage(message) + commands, err := common.ParseGameStatsMessage(message) if err != nil { logging.Error(session.ModuleName, "Error parsing message:", err.Error()) logging.Error(session.ModuleName, "Raw data:", message) diff --git a/gamestats/setpd.go b/gamestats/setpd.go index 9c1fa8f..85d2acf 100644 --- a/gamestats/setpd.go +++ b/gamestats/setpd.go @@ -2,20 +2,113 @@ package gamestats import ( "strconv" + "strings" "time" "wwfc/common" + "wwfc/database" + "wwfc/logging" + + "github.com/jackc/pgx/v4" + "github.com/logrusorgru/aurora/v3" ) func (g *GameStatsSession) setpd(command common.GameSpyCommand) { + // Example (with formatting): + // \setpd\ + // \pid\1000000004 + // \ptype\3 + // \dindex\0 + // \kv\1 + // \lid\0 + // \length\149 + // \data\ + // \itast_friend_p\AFAAYQBsAGEAcABlAGwAaQAAAAAAANzmAFAAYQBsAGEAcABlAGwAaQAAAABSJYgbuuQEbA5pAASOoAk9JpJsjKhAFEmQTQCKAIolBAAAAAAAAAAAAAAAAAAAAAAAAAAAGps*\x00 + // \final\ + + errMsg := common.GameSpyCommand{ + Command: "setpdr", + CommandValue: "0", + OtherValues: map[string]string{ + "pid": command.OtherValues["pid"], + "lid": strconv.Itoa(g.LoginID), + }, + } + + if command.OtherValues["pid"] != strconv.FormatUint(uint64(g.User.ProfileId), 10) { + logging.Error(g.ModuleName, "Invalid profile ID:", aurora.Cyan(command.OtherValues["pid"])) + g.Write(errMsg) + return + } + + dindex, ok := command.OtherValues["dindex"] + if !ok { + logging.Error(g.ModuleName, "Missing dindex") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + ptype, ok := command.OtherValues["ptype"] + if !ok { + logging.Error(g.ModuleName, "Missing ptype") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + newData, ok := command.OtherValues["data"] + if !ok { + logging.Error(g.ModuleName, "Missing data") + logging.Error(g.ModuleName, "Full command:", command) + g.Write(errMsg) + return + } + + logging.Info(g.ModuleName, "Set public data: PID:", aurora.Cyan(g.User.ProfileId), "Index:", aurora.Cyan(dindex), "Type:", aurora.Cyan(ptype), "Data:", aurora.Cyan(newData)) + + // Trim extra null byte at the end + if len(newData) > 0 && newData[len(newData)-1] == 0 { + newData = newData[:len(newData)-1] + } + + if strings.ContainsRune(newData, 0) { + logging.Error(g.ModuleName, "Data contains null byte") + g.Write(errMsg) + return + } + + var modifiedTime time.Time + _, _, err := database.GetGameStatsPublicData(pool, ctx, g.User.ProfileId, dindex, ptype) + if err != nil { + if err != pgx.ErrNoRows { + logging.Error(g.ModuleName, "GetGameStatsPublicData returned", err) + g.Write(errMsg) + return + } + + modifiedTime, err = database.CreateGameStatsPublicData(pool, ctx, g.User.ProfileId, dindex, ptype, newData) + if err != nil { + logging.Error(g.ModuleName, "GetGameStatsPublicData returned", err) + g.Write(errMsg) + return + } + } else { + modifiedTime, err = database.UpdateGameStatsPublicData(pool, ctx, g.User.ProfileId, dindex, ptype, newData) + if err != nil { + logging.Error(g.ModuleName, "UpdateGameStatsPublicData returned", err) + g.Write(errMsg) + return + } + } + + // TODO: Is mod supposed to be the last modified time or new modified time? g.Write(common.GameSpyCommand{ Command: "setpdr", CommandValue: "1", OtherValues: map[string]string{ - "lid": strconv.Itoa(g.LoginID), - "pid": command.OtherValues["pid"], - "mod": strconv.Itoa(int(time.Now().Unix())), - "length": "0", - "data": `\\`, + "lid": strconv.Itoa(g.LoginID), + "pid": command.OtherValues["pid"], + "mod": strconv.Itoa(int(modifiedTime.Unix())), }, }) } diff --git a/schema.sql b/schema.sql index 79fa1c2..d040cab 100644 --- a/schema.sql +++ b/schema.sql @@ -86,6 +86,20 @@ ALTER TABLE ONLY public.mario_kart_wii_sake ALTER TABLE public.mario_kart_wii_sake OWNER TO wiilink; +-- +-- Name: gamestats_public_data; Type: TABLE; Schema: public; Owner: wiilink +-- + +CREATE TABLE IF NOT EXISTS public.gamestats_public_data ( + profile_id bigint NOT NULL, + dindex character varying NOT NULL, + ptype character varying NOT NULL, + pdata character varying NOT NULL, + modified_time timestamp without time zone NOT NULL, + + CONSTRAINT one_pdata_constraint UNIQUE (profile_id, dindex, ptype) +); + -- -- Name: users_profile_id_seq; Type: SEQUENCE; Schema: public; Owner: wiilink --