From 0c4f6caec1717a3c0aa28a8716a6719d6c92cc83 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 02:49:10 -0700 Subject: [PATCH] Products: chat command filter, request signing, saved state Co-Authored-By: Claude Opus 5.5 --- internal/products/chat.go | 80 ++++++++++++++++++++++ internal/products/chat_test.go | 46 +++++++++++++ internal/products/sign.go | 68 +++++++++++++++++++ internal/products/state.go | 119 +++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 internal/products/chat.go create mode 100644 internal/products/chat_test.go create mode 100644 internal/products/sign.go create mode 100644 internal/products/state.go diff --git a/internal/products/chat.go b/internal/products/chat.go new file mode 100644 index 0000000..26a2a97 --- /dev/null +++ b/internal/products/chat.go @@ -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 +} diff --git a/internal/products/chat_test.go b/internal/products/chat_test.go new file mode 100644 index 0000000..9d67fee --- /dev/null +++ b/internal/products/chat_test.go @@ -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") + } +} diff --git a/internal/products/sign.go b/internal/products/sign.go new file mode 100644 index 0000000..a637aad --- /dev/null +++ b/internal/products/sign.go @@ -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(`securityKey\s*(?:)?([0-9A-Za-z]+)`) +var userIDPattern = regexp.MustCompile(`userId\s*(?:||)?([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 +} diff --git a/internal/products/state.go b/internal/products/state.go new file mode 100644 index 0000000..45467c6 --- /dev/null +++ b/internal/products/state.go @@ -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 /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) +}