Products: chat command filter, request signing, saved state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2190230f81
commit
0c4f6caec1
4 files changed
+313
No files matched your search
@@ -0,0 +1,80 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// outfitCommands are the chat commands whose arguments are product IDs: putting products on and taking them off.
|
||||
var outfitCommands = map[string]bool{
|
||||
"*use": true, "*putOn": true, "*putOnOutfit": true, "*remove": true, "*takeOff": true,
|
||||
}
|
||||
|
||||
// bodyProduct is the avatar body every full outfit starts from; a *use list with it is a whole outfit (the client
|
||||
// re-sends its outfit that way every 30 s or so).
|
||||
const bodyProduct = 80
|
||||
|
||||
// strippedCommand is a chat line with community products taken out of its outfit command.
|
||||
type strippedCommand struct {
|
||||
text string // the line to send instead
|
||||
changed bool // products were taken out
|
||||
empty bool // nothing is left to send
|
||||
outfit bool // the line was a whole outfit (*use with the body)
|
||||
products []int64 // the community products it named
|
||||
}
|
||||
|
||||
// stripCommand takes products for which isCommunity is true out of an outfit command's arguments.
|
||||
func stripCommand(text string, isCommunity func(int64) bool) strippedCommand {
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) == 0 || !outfitCommands[fields[0]] {
|
||||
return strippedCommand{text: text}
|
||||
}
|
||||
r := strippedCommand{text: text}
|
||||
kept := []string{fields[0]}
|
||||
for _, f := range fields[1:] {
|
||||
id, err := strconv.ParseInt(f, 10, 64)
|
||||
if err == nil && id == bodyProduct && fields[0] == "*use" {
|
||||
r.outfit = true
|
||||
}
|
||||
if err == nil && isCommunity(id) {
|
||||
r.products = append(r.products, id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
if len(r.products) == 0 {
|
||||
return r
|
||||
}
|
||||
r.changed = true
|
||||
r.empty = len(kept) == 1
|
||||
r.text = strings.Join(kept, " ")
|
||||
return r
|
||||
}
|
||||
|
||||
// chatMessage is the JSON the client sends on a chat queue's "messages" mount.
|
||||
type chatMessage struct {
|
||||
fields map[string]json.RawMessage
|
||||
text string
|
||||
userID int64
|
||||
}
|
||||
|
||||
func parseChatMessage(b []byte) (*chatMessage, bool) {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(b, &fields) != nil {
|
||||
return nil, false
|
||||
}
|
||||
m := &chatMessage{fields: fields}
|
||||
if json.Unmarshal(fields["message"], &m.text) != nil {
|
||||
return nil, false
|
||||
}
|
||||
json.Unmarshal(fields["userId"], &m.userID)
|
||||
return m, true
|
||||
}
|
||||
|
||||
func (m *chatMessage) withText(text string) []byte {
|
||||
b, _ := json.Marshal(text)
|
||||
m.fields["message"] = b
|
||||
out, _ := json.Marshal(m.fields)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func community(id int64) bool { return id >= 2000000000 }
|
||||
|
||||
func TestStripCommand(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in, out string
|
||||
empty, outfit bool
|
||||
products string
|
||||
}{
|
||||
{"*use 80 123 2000000001 456", "*use 80 123 456", false, true, "[2000000001]"},
|
||||
{"*use 2000000001", "*use", true, false, "[2000000001]"},
|
||||
{"*putOnOutfit 5 2000000002 2000000003", "*putOnOutfit 5", false, false, "[2000000002 2000000003]"},
|
||||
{"*remove 2000000001", "*remove", true, false, "[2000000001]"},
|
||||
{"*use 80 123", "*use 80 123", false, true, "[]"},
|
||||
{"hello 2000000001", "hello 2000000001", false, false, "[]"},
|
||||
{"*imvu:setRoomState c...", "*imvu:setRoomState c...", false, false, "[]"},
|
||||
} {
|
||||
r := stripCommand(c.in, community)
|
||||
if r.text != c.out || r.empty != c.empty || r.outfit != c.outfit || fmt.Sprint(r.products) != c.products {
|
||||
t.Errorf("%q: got %+v", c.in, r)
|
||||
}
|
||||
if r.changed != (len(r.products) > 0) {
|
||||
t.Errorf("%q: changed %v", c.in, r.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessage(t *testing.T) {
|
||||
m, ok := parseChatMessage([]byte(`{"to": 0, "message": "*use 80 2000000001", "userId": 392, "chatId": 77}`))
|
||||
if !ok || m.text != "*use 80 2000000001" || m.userID != 392 {
|
||||
t.Fatalf("%+v %v", m, ok)
|
||||
}
|
||||
out := string(m.withText("*use 80"))
|
||||
if out != `{"chatId":77,"message":"*use 80","to":0,"userId":392}` {
|
||||
t.Fatal(out)
|
||||
}
|
||||
if _, ok := parseChatMessage([]byte(`not json`)); ok {
|
||||
t.Fatal("parsed non-JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// IMVU signs the client's requests: X-imvu-auth is md5(user ID + session security key + body), with the query
|
||||
// string added for some calls. The proxy re-signs requests it changes (taking community products out), with the key
|
||||
// from the login response. The key never leaves this machine.
|
||||
|
||||
func sign(user, key string, body []byte, query string, withQuery bool) string {
|
||||
h := md5.New()
|
||||
h.Write([]byte(user + key))
|
||||
h.Write(body)
|
||||
if withQuery {
|
||||
h.Write([]byte(query))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// keys holds each signed-in user's security key.
|
||||
type keys struct {
|
||||
mu sync.Mutex
|
||||
byID map[string]string
|
||||
}
|
||||
|
||||
var securityKeyPattern = regexp.MustCompile(`<name>securityKey</name>\s*<value>(?:<string>)?([0-9A-Za-z]+)`)
|
||||
var userIDPattern = regexp.MustCompile(`<name>userId</name>\s*<value>(?:<int>|<i4>|<string>)?([0-9]+)`)
|
||||
|
||||
// learn takes the user's key from a login response (test.avatarInfoForLogin2).
|
||||
func (k *keys) learn(body []byte) {
|
||||
key, user := securityKeyPattern.FindSubmatch(body), userIDPattern.FindSubmatch(body)
|
||||
if key == nil || user == nil {
|
||||
return
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.byID == nil {
|
||||
k.byID = map[string]string{}
|
||||
}
|
||||
k.byID[string(user[1])] = string(key[1])
|
||||
}
|
||||
|
||||
// resign recomputes X-imvu-auth for a request whose body or query changed, in the scheme the original used. It
|
||||
// reports false if it couldn't (no key, or the original signature didn't match either scheme).
|
||||
func (k *keys) resign(h http.Header, oldBody []byte, oldQuery string, newBody []byte, newQuery string) bool {
|
||||
auth, user := h.Get("X-imvu-auth"), h.Get("X-imvu-userid")
|
||||
if auth == "" {
|
||||
return true // not signed
|
||||
}
|
||||
k.mu.Lock()
|
||||
key, ok := k.byID[user]
|
||||
k.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, withQuery := range []bool{false, true} {
|
||||
if sign(user, key, oldBody, oldQuery, withQuery) == auth {
|
||||
h.Set("X-imvu-auth", sign(user, key, newBody, newQuery, withQuery))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FirstLocalID is where the client's numbers for community products start. IMVU's product IDs are far below
|
||||
// (about 72 million in 2026), and the client's XML-RPC can't carry numbers past 2^31-1.
|
||||
const FirstLocalID = 2_000_000_000
|
||||
|
||||
// state is what the proxy remembers between runs, in <data dir>/products.json: the local ID of every product it
|
||||
// has served (so outfits keep naming the same product), and the community products each IMVU account wears (IMVU
|
||||
// never sees them, so it can't remember them).
|
||||
type state struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
data stateData
|
||||
}
|
||||
|
||||
type stateData struct {
|
||||
IDs map[string]int64 `json:"ids"` // product ID -> local ID
|
||||
Worn map[string][]string `json:"worn"` // IMVU user ID -> product IDs
|
||||
}
|
||||
|
||||
func loadState(path string) (*state, error) {
|
||||
s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string][]string{}}}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.data.IDs == nil {
|
||||
s.data.IDs = map[string]int64{}
|
||||
}
|
||||
if s.data.Worn == nil {
|
||||
s.data.Worn = map[string][]string{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// localID is the product's local ID, given one if it has none yet.
|
||||
func (s *state) localID(product string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id, ok := s.data.IDs[product]; ok {
|
||||
return id, nil
|
||||
}
|
||||
next := int64(FirstLocalID)
|
||||
for _, id := range s.data.IDs {
|
||||
next = max(next, id+1)
|
||||
}
|
||||
if next > 2_147_483_647 {
|
||||
return 0, errors.New("products: out of local IDs")
|
||||
}
|
||||
s.data.IDs[product] = next
|
||||
return next, s.saveLocked()
|
||||
}
|
||||
|
||||
// worn is the community products the user wears.
|
||||
func (s *state) worn(user string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.data.Worn[user]...)
|
||||
}
|
||||
|
||||
// setWorn records the community products the user wears.
|
||||
func (s *state) setWorn(user string, products []string) error {
|
||||
products = append([]string(nil), products...)
|
||||
sort.Strings(products)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if equal(s.data.Worn[user], products) {
|
||||
return nil
|
||||
}
|
||||
if len(products) == 0 {
|
||||
delete(s.data.Worn, user)
|
||||
} else {
|
||||
s.data.Worn[user] = products
|
||||
}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *state) saveLocked() error {
|
||||
b, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
Reference in new issue
Block a user